Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
792 lines
31 KiB
Go
792 lines
31 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func (a *CodexAppServerAdapter) steerActiveTurn(
|
|
ctx context.Context,
|
|
appSession *codexAppServerSession,
|
|
session Session,
|
|
content []PromptContentBlock,
|
|
providerContent []PromptContentBlock,
|
|
explicitDisplayPrompt string,
|
|
displayPrompt string,
|
|
turnID string,
|
|
activeTurnID string,
|
|
emit EventSink,
|
|
) ([]activityshared.Event, error) {
|
|
timeout := a.turnSteerTimeout
|
|
if timeout <= 0 {
|
|
timeout = defaultCodexAppServerTurnSteerTimeout
|
|
}
|
|
_, err := appSession.client.TurnSteerNoHandler(ctx, timeout, map[string]any{
|
|
"threadId": appSession.threadID,
|
|
"expectedTurnId": activeTurnID,
|
|
"input": appServerUserInput(providerContent),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
events := []activityshared.Event{
|
|
newUserPromptActivityEvent(ctx, session, content, explicitDisplayPrompt, displayPrompt, turnID, map[string]any{
|
|
"guidance": true,
|
|
"steered": true,
|
|
}),
|
|
}
|
|
if emit != nil {
|
|
emit(events)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// GoalControl performs a direct goal action from the GUI without going
|
|
// through the prompt pipeline: no user message, no turn, no transcript entry
|
|
// — only the goal RPC plus a goal-updated session event so the banner
|
|
// refreshes, matching the codex desktop goal bar's in-place controls.
|
|
func (*CodexAppServerAdapter) GoalCapabilities() GoalAdapterCapabilities {
|
|
return GoalAdapterCapabilities{
|
|
QuerySupported: true, ClearSupported: true, PauseSupported: true,
|
|
QuiesceGoalTurns: true, ReplaySetAfterRestart: true,
|
|
}
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) ApplyGoal(
|
|
ctx context.Context,
|
|
session Session,
|
|
input GoalApplyInput,
|
|
) (GoalAdapterResult, error) {
|
|
action := input.Action
|
|
objective := input.Objective
|
|
appSession := a.getSession(session.AgentSessionID)
|
|
if appSession == nil || appSession.client == nil {
|
|
return GoalAdapterResult{}, ErrSessionDisconnected
|
|
}
|
|
appSession.goalMutationMu.Lock()
|
|
defer appSession.goalMutationMu.Unlock()
|
|
session.ProviderSessionID = appSession.threadID
|
|
method := appServerMethodThreadGoalSet
|
|
params := map[string]any{"threadId": appSession.threadID}
|
|
switch action {
|
|
case GoalControlPause:
|
|
params["status"] = "paused"
|
|
case GoalControlResume:
|
|
params["status"] = "active"
|
|
case GoalControlClear:
|
|
method = appServerMethodThreadGoalClear
|
|
case GoalControlSet:
|
|
objective = strings.TrimSpace(objective)
|
|
if objective == "" {
|
|
return GoalAdapterResult{}, fmt.Errorf("goal objective is required")
|
|
}
|
|
params["objective"] = objective
|
|
params["status"] = "active"
|
|
default:
|
|
return GoalAdapterResult{}, fmt.Errorf("unsupported goal control action %q", action)
|
|
}
|
|
previousOperationID, previousRevision, previousRepairEpoch := a.replaceGoalOperationIdentity(session.AgentSessionID, input.OperationID, input.Revision, input.RepairEpoch)
|
|
expectedIdentity := goalOperationIdentity{
|
|
operationID: strings.TrimSpace(input.OperationID),
|
|
revision: input.Revision,
|
|
repairEpoch: input.RepairEpoch,
|
|
}
|
|
if action == GoalControlSet || action == GoalControlResume {
|
|
a.prepareGoalContinuationClaim(session.AgentSessionID, expectedIdentity)
|
|
defer a.clearPreparedGoalContinuationClaim(session.AgentSessionID, expectedIdentity)
|
|
}
|
|
slog.Info("agent session app-server goal control",
|
|
"event", "agent_session.app_server.goal.control",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"action", string(action),
|
|
)
|
|
result, err := appSession.callGoalNoHandler(ctx, method, params)
|
|
if err != nil {
|
|
a.restoreGoalOperationIdentity(session.AgentSessionID, input.OperationID, input.Revision, input.RepairEpoch, previousOperationID, previousRevision, previousRepairEpoch)
|
|
return GoalAdapterResult{}, err
|
|
}
|
|
goalUpdateType := "thread_goal_update"
|
|
var goal map[string]any
|
|
if method == appServerMethodThreadGoalClear {
|
|
a.applyGoalClear(session.AgentSessionID)
|
|
goalUpdateType = "thread_goal_cleared"
|
|
} else if action == GoalControlPause || action == GoalControlResume {
|
|
// Pause/resume are status-only durable controls. They must not re-bind
|
|
// the provider generation fingerprint to the new operation id: the
|
|
// set/adoption that created the generation already owns that ledger
|
|
// row, and a second bind marks the fingerprint permanently ambiguous.
|
|
goal = appServerGoalFromResult(result)
|
|
if len(goal) == 0 {
|
|
goal = clonePayload(a.sessionGoal(session.AgentSessionID))
|
|
if len(goal) > 0 {
|
|
if action == GoalControlPause {
|
|
goal["status"] = "paused"
|
|
} else {
|
|
goal["status"] = "active"
|
|
}
|
|
}
|
|
}
|
|
if len(goal) > 0 {
|
|
a.applyGoalUpdate(session.AgentSessionID, goal)
|
|
}
|
|
} else {
|
|
goal = appServerGoalFromResult(result)
|
|
if len(goal) > 0 {
|
|
if bindErr := a.bindGoalGeneration(ctx, session, goal, expectedIdentity); bindErr != nil {
|
|
return GoalAdapterResult{}, fmt.Errorf("persist goal provenance: %w", bindErr)
|
|
}
|
|
a.applyGoalUpdate(session.AgentSessionID, goal)
|
|
} else if action == GoalControlSet && expectedIdentity.valid() {
|
|
err := errors.New("provider returned no Goal generation for durable Goal set")
|
|
a.failGoalProvenanceSession(session, err)
|
|
return GoalAdapterResult{}, err
|
|
}
|
|
}
|
|
if (action == GoalControlSet || action == GoalControlResume) && len(goal) > 0 {
|
|
a.armGoalContinuationClaim(session.AgentSessionID, expectedIdentity)
|
|
}
|
|
var events []activityshared.Event
|
|
if event, ok := normalizedGoalUpdatedEvent(session, goalUpdateType); ok {
|
|
events = append(events, event)
|
|
}
|
|
if action == GoalControlSet {
|
|
// Codex normally starts the next goal turn itself after set; the nudge
|
|
// covers the case where it does not. Resume must not schedule this
|
|
// delayed poke: the grace window spans the remaining Goal work, so a
|
|
// status=active set at the end races the provider's natural complete
|
|
// and can restart the Goal into an unbounded continuation loop.
|
|
a.scheduleGoalContinuationNudge(session)
|
|
}
|
|
observation := a.sessionGoal(session.AgentSessionID)
|
|
return GoalAdapterResult{
|
|
Events: events, Observation: observation,
|
|
Evidence: map[string]any{"source": "codex_goal_rpc", "confidence": "authoritative", "repairEpoch": input.RepairEpoch},
|
|
ProviderPhase: "applied",
|
|
ExecutionPending: action == GoalControlSet,
|
|
}, nil
|
|
}
|
|
|
|
// GoalControl is retained as an adapter-level compatibility shim for focused
|
|
// provider tests; controller consumers use the semantic ApplyGoal contract.
|
|
func (a *CodexAppServerAdapter) GoalControl(ctx context.Context, session Session, action GoalControlAction, objective string) ([]activityshared.Event, map[string]any, error) {
|
|
result, err := a.ApplyGoal(ctx, session, GoalApplyInput{Action: action, Objective: objective})
|
|
return result.Events, result.Observation, err
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) ReconcileGoal(ctx context.Context, session Session) (GoalAdapterResult, error) {
|
|
appSession := a.getSession(session.AgentSessionID)
|
|
if appSession == nil || appSession.client == nil {
|
|
return GoalAdapterResult{}, ErrSessionDisconnected
|
|
}
|
|
appSession.goalMutationMu.Lock()
|
|
defer appSession.goalMutationMu.Unlock()
|
|
result, err := appSession.callGoalNoHandler(ctx, appServerMethodThreadGoalGet, map[string]any{"threadId": appSession.threadID})
|
|
if err != nil {
|
|
return GoalAdapterResult{}, err
|
|
}
|
|
goal := appServerGoalFromResult(result)
|
|
updateType := "thread_goal_update"
|
|
if len(goal) == 0 {
|
|
a.applyGoalClear(session.AgentSessionID)
|
|
updateType = "thread_goal_cleared"
|
|
} else {
|
|
a.applyGoalUpdate(session.AgentSessionID, goal)
|
|
}
|
|
events := []activityshared.Event{}
|
|
if event, ok := normalizedGoalUpdatedEvent(session, updateType); ok {
|
|
events = append(events, event)
|
|
}
|
|
return GoalAdapterResult{Events: events, Observation: goal, Evidence: map[string]any{
|
|
"source": "codex_goal_get", "confidence": "authoritative",
|
|
}}, nil
|
|
}
|
|
|
|
func (*CodexAppServerAdapter) NormalizeGoalObservation(raw map[string]any) map[string]any {
|
|
return normalizedCodexGoal(raw)
|
|
}
|
|
|
|
// normalizedCodexGoal translates provider-specific app-server fields into the
|
|
// canonical Tutti SessionGoal contract. Provider payloads use seconds and
|
|
// tokensUsed; the durable/public model uses milliseconds and tokens.
|
|
func normalizedCodexGoal(raw map[string]any) map[string]any {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
goal := map[string]any{}
|
|
if objective := strings.TrimSpace(asStringRaw(raw["objective"])); objective != "" {
|
|
goal["objective"] = objective
|
|
}
|
|
status := appServerGoalStatus(asString(raw["status"]))
|
|
if status == "" {
|
|
status = strings.TrimSpace(asString(raw["status"]))
|
|
}
|
|
if status != "" {
|
|
goal["status"] = status
|
|
}
|
|
if reason := strings.TrimSpace(asStringRaw(raw["reason"])); reason != "" {
|
|
goal["reason"] = reason
|
|
}
|
|
if iterations, ok := int64Value(raw["iterations"]); ok && iterations >= 0 {
|
|
goal["iterations"] = iterations
|
|
}
|
|
if durationMS, ok := int64Value(raw["durationMs"]); ok && durationMS >= 0 {
|
|
goal["durationMs"] = durationMS
|
|
} else if timeUsedSeconds, ok := int64Value(raw["timeUsedSeconds"]); ok && timeUsedSeconds >= 0 {
|
|
goal["durationMs"] = timeUsedSeconds * int64(time.Second/time.Millisecond)
|
|
}
|
|
if tokens, ok := int64Value(raw["tokens"]); ok && tokens >= 0 {
|
|
goal["tokens"] = tokens
|
|
} else if tokensUsed, ok := int64Value(raw["tokensUsed"]); ok && tokensUsed >= 0 {
|
|
goal["tokens"] = tokensUsed
|
|
}
|
|
return goal
|
|
}
|
|
|
|
// ExecGoalControl executes a /goal control command as a thread-level
|
|
// operation without opening a turn. The controller routes here when another
|
|
// turn already holds the session's turn slot, so the goal banner's
|
|
// pause/resume/delete act immediately instead of being rejected by the
|
|
// single-turn gate. handled is false when the prompt is not a /goal command.
|
|
func (a *CodexAppServerAdapter) ExecGoalControl(
|
|
ctx context.Context,
|
|
session Session,
|
|
content []PromptContentBlock,
|
|
displayPrompt string,
|
|
) ([]activityshared.Event, bool, error) {
|
|
explicitDisplayPrompt, visibleText := explicitAndVisiblePromptText(content, displayPrompt)
|
|
command, args := splitSlashCommand(visibleText)
|
|
if command != appServerSlashGoal {
|
|
return nil, false, nil
|
|
}
|
|
appSession := a.getSession(session.AgentSessionID)
|
|
if appSession == nil || appSession.client == nil {
|
|
return nil, true, ErrSessionDisconnected
|
|
}
|
|
session.ProviderSessionID = appSession.threadID
|
|
events, err := a.execGoalControlCommand(ctx, appSession, session, args, "", content, explicitDisplayPrompt, visibleText, nil, nil)
|
|
return events, true, err
|
|
}
|
|
|
|
// execGoalControlCommand executes /goal while another turn is running. The
|
|
// goal RPC runs against the thread (not a turn); the submission is recorded
|
|
// as a session-level audit message. A /goal with a new objective can update
|
|
// the goal while another turn is active; continuation turns are adopted only
|
|
// when the provider actually starts them.
|
|
func (a *CodexAppServerAdapter) execGoalControlCommand(
|
|
ctx context.Context,
|
|
appSession *codexAppServerSession,
|
|
session Session,
|
|
args string,
|
|
turnID string,
|
|
content []PromptContentBlock,
|
|
explicitDisplayPrompt string,
|
|
displayPrompt string,
|
|
emit EventSink,
|
|
reportDispatch ProviderDispatchSink,
|
|
) ([]activityshared.Event, error) {
|
|
method, params := appServerGoalSlashRequest(args, appSession.threadID)
|
|
// NoHandler: the active turn keeps streaming while this control RPC runs;
|
|
// claiming the handler slot would swallow its notifications.
|
|
result, err := appSession.callGoalNoHandler(ctx, method, params)
|
|
events := []activityshared.Event{
|
|
newSessionAuditEventWithID(session, newID(), RoleUser, displayPrompt, userPromptActivityPayload(content, explicitDisplayPrompt, userPromptActivityPayloadExtraFromExecMetadata(ctx, map[string]any{
|
|
"goalControl": true,
|
|
}))),
|
|
}
|
|
if err != nil {
|
|
reportCodexDispatchFailure(reportDispatch, err)
|
|
if strings.TrimSpace(turnID) != "" {
|
|
events = append(events, appServerSystemNoticeEvent(session, turnID, "warning", "Goal command failed.", err.Error()))
|
|
}
|
|
if emit != nil {
|
|
emit(events)
|
|
}
|
|
return events, nil
|
|
}
|
|
reportCodexAppliedWithoutProviderTurn(reportDispatch)
|
|
goalUpdateType := "thread_goal_update"
|
|
if method == appServerMethodThreadGoalClear {
|
|
a.applyGoalClear(session.AgentSessionID)
|
|
goalUpdateType = "thread_goal_cleared"
|
|
} else if goal := appServerGoalFromResult(result); len(goal) > 0 {
|
|
a.applyGoalUpdate(session.AgentSessionID, goal)
|
|
}
|
|
if event, ok := normalizedGoalUpdatedEvent(session, goalUpdateType); ok {
|
|
events = append(events, event)
|
|
}
|
|
if notice := appServerGoalNoticeEvent(session, turnID, method, result); notice != nil && strings.TrimSpace(turnID) != "" {
|
|
events = append(events, *notice)
|
|
}
|
|
if emit != nil {
|
|
emit(events)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) execSlashCommand(
|
|
ctx context.Context,
|
|
appSession *codexAppServerSession,
|
|
session Session,
|
|
displayPrompt string,
|
|
turnID string,
|
|
appTurn *codexAppServerActiveTurn,
|
|
normalizer *acpTurnNormalizer,
|
|
emitEvents func([]activityshared.Event),
|
|
emitTerminal func([]activityshared.Event),
|
|
emitCommands CommandSnapshotSink,
|
|
reportDispatch ProviderDispatchSink,
|
|
admitProviderTurn func(string) error,
|
|
admitWithoutProviderTurn func(),
|
|
) (bool, error) {
|
|
command, args := splitSlashCommand(displayPrompt)
|
|
switch command {
|
|
case appServerSlashCompact:
|
|
a.transitionActiveTurnPhase(session.AgentSessionID, appTurn, codexAppServerTurnPhaseCompacting)
|
|
// Emit the "Compacting context." banner up front instead of waiting for
|
|
// the server's contextCompaction item/started notification: Codex
|
|
// app-server frequently finishes thread/compact/start without ever
|
|
// streaming that notification, which used to leave the whole operation
|
|
// invisible until (if ever) an item/completed notice arrived. Tracking
|
|
// the messageId now means a later item/started reuses this same row
|
|
// (see appServerItemEvents) instead of appending a duplicate, and an
|
|
// immediate RPC failure or an interrupted/failed turn always has a
|
|
// pending banner to settle in place.
|
|
startMessageID := "compaction:" + turnID
|
|
startMessageID, _ = normalizer.StartCompactionNotice(startMessageID)
|
|
emitEvents([]activityshared.Event{appServerCompactionNoticeEvent(session, turnID, startMessageID, "running")})
|
|
_, err := appSession.client.ThreadCompactStart(ctx, map[string]any{
|
|
"threadId": appSession.threadID,
|
|
}, a.appServerMessageHandler(appSession, session, turnID, normalizer, emitEvents, emitCommands))
|
|
if err != nil {
|
|
reportCodexDispatchFailure(reportDispatch, err)
|
|
emitTerminal(append(
|
|
normalizer.settlePendingCompactionEvents(session, turnID, "failed"),
|
|
newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(err)),
|
|
))
|
|
return true, nil
|
|
}
|
|
admitWithoutProviderTurn()
|
|
// Block until the App Server signals turn/completed. The session-level
|
|
// handler keeps activeTurn alive during this wait, so the
|
|
// contextCompaction item/completed notification fires appServerItemEvents
|
|
// and emits the "Context compacted." banner through emitEvents before we
|
|
// close the turn.
|
|
_, finishErr := a.awaitTurnCompletion(ctx, appSession, appTurn, nil)
|
|
if finishErr != nil {
|
|
if errors.Is(finishErr, context.Canceled) || errors.Is(finishErr, errPermissionRequestCanceled) || a.turnForceCanceled(appTurn) {
|
|
emitTerminal(append(
|
|
normalizer.FinishInterrupted(session, turnID, "interrupted"),
|
|
newTurnActivityEvent(session, EventTurnCanceled, turnID, SessionStatusCanceled, "", "", map[string]any{
|
|
"error": finishErr.Error(),
|
|
}),
|
|
))
|
|
} else {
|
|
emitTerminal(append(
|
|
normalizer.FinishFailed(session, turnID),
|
|
newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(finishErr)),
|
|
))
|
|
}
|
|
return true, nil
|
|
}
|
|
emitTerminal(append(
|
|
normalizer.FinishCompleted(session, turnID),
|
|
newTurnActivityEvent(session, EventTurnCompleted, turnID, SessionStatusReady, "", "", map[string]any{
|
|
"stopReason": "end_turn",
|
|
"completedCommandKind": "compact",
|
|
"completedCommandStatus": "completed",
|
|
}),
|
|
))
|
|
return true, nil
|
|
case appServerSlashGoal:
|
|
method, params := appServerGoalSlashRequest(args, appSession.threadID)
|
|
goalObjective := strings.TrimSpace(asString(params["objective"]))
|
|
goalDrivesTurn := method == appServerMethodThreadGoalSet && goalObjective != ""
|
|
expectedGoalIdentity := goalOperationIdentity{}
|
|
var previousGoal map[string]any
|
|
if goalDrivesTurn {
|
|
expectedGoalIdentity = a.goalOperationIdentity(session.AgentSessionID)
|
|
// Mark before the RPC: the server may start (and even settle) the
|
|
// goal's first turn while thread/goal/set is still in flight, and
|
|
// the settle path only emits terminal events for marked turns. The
|
|
// same ordering requires the local goal to be active before the RPC:
|
|
// finalizeSettledTurn schedules continuation from that state.
|
|
previousGoal = a.sessionGoal(session.AgentSessionID)
|
|
pendingGoal := clonePayload(previousGoal)
|
|
if pendingGoal == nil {
|
|
pendingGoal = map[string]any{}
|
|
}
|
|
pendingGoal["objective"] = goalObjective
|
|
pendingGoal["status"] = "active"
|
|
a.applyGoalUpdate(session.AgentSessionID, pendingGoal)
|
|
a.markTurnSettleEmits(appTurn)
|
|
go a.watchTurnExternalTermination(appSession, appTurn)
|
|
}
|
|
result, err := appSession.callGoal(ctx, method, params,
|
|
a.appServerMessageHandler(appSession, session, turnID, normalizer, emitEvents, emitCommands))
|
|
if err != nil {
|
|
reportCodexDispatchFailure(reportDispatch, err)
|
|
if goalDrivesTurn {
|
|
if len(previousGoal) > 0 {
|
|
a.applyGoalUpdate(session.AgentSessionID, previousGoal)
|
|
} else {
|
|
a.applyGoalClear(session.AgentSessionID)
|
|
}
|
|
}
|
|
emitTerminal([]activityshared.Event{newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(err))})
|
|
return true, nil
|
|
}
|
|
if goalDrivesTurn {
|
|
initialTurn := appServerTurnFromResult(result)
|
|
if err := admitProviderTurn(asString(initialTurn["id"])); err != nil {
|
|
return true, err
|
|
}
|
|
} else {
|
|
admitWithoutProviderTurn()
|
|
}
|
|
if method == appServerMethodThreadGoalClear {
|
|
a.applyGoalClear(session.AgentSessionID)
|
|
if event, ok := normalizedGoalUpdatedEvent(session, "thread_goal_cleared"); ok {
|
|
emitEvents([]activityshared.Event{event})
|
|
}
|
|
} else if goal := appServerGoalFromResult(result); len(goal) > 0 {
|
|
if current := a.goalOperationIdentity(session.AgentSessionID); current == expectedGoalIdentity {
|
|
if bindErr := a.bindGoalGeneration(ctx, session, goal, expectedGoalIdentity); bindErr != nil {
|
|
emitTerminal([]activityshared.Event{newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(bindErr))})
|
|
return true, nil
|
|
}
|
|
}
|
|
a.applyGoalUpdate(session.AgentSessionID, goal)
|
|
if event, ok := normalizedGoalUpdatedEvent(session, "thread_goal_update"); ok {
|
|
emitEvents([]activityshared.Event{event})
|
|
}
|
|
}
|
|
if goalDrivesTurn {
|
|
// The settle path (notification loop) owns terminal production for
|
|
// the goal's first turn, exactly like a normal turn. Continuation
|
|
// turns that codex starts on its own afterwards are adopted by the
|
|
// reducer (adoptServerInitiatedTurn) and render as their own turns,
|
|
// so goal progress never depends on this Exec staying alive.
|
|
initialTurn := appServerTurnFromResult(result)
|
|
if providerTurnID := asString(initialTurn["id"]); providerTurnID != "" {
|
|
if a.setSessionActiveTurnID(session.AgentSessionID, appTurn, providerTurnID) {
|
|
a.interruptActiveTurnAsync(appSession, session, appTurn, providerTurnID, "queued cancel")
|
|
}
|
|
}
|
|
finalTurn, finishErr := a.awaitTurnCompletion(ctx, appSession, appTurn, initialTurn)
|
|
select {
|
|
case <-appTurn.terminated:
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
a.endActiveTurn(session.AgentSessionID, appTurn)
|
|
if appTurn.settleFinalized.Load() {
|
|
return true, nil
|
|
}
|
|
slog.Warn(
|
|
"agent session app-server goal turn terminal produced by blocking shell (settle shadow miss)",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"turn_id", turnID,
|
|
)
|
|
if finishErr != nil {
|
|
if errors.Is(finishErr, context.Canceled) || errors.Is(finishErr, errPermissionRequestCanceled) || a.turnForceCanceled(appTurn) {
|
|
terminalEvents := a.pendingRequestFailureEvents(session, turnID, errPermissionRequestCanceled)
|
|
terminalEvents = append(terminalEvents, normalizer.FinishInterrupted(session, turnID, "interrupted")...)
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnCanceled, turnID, SessionStatusCanceled, "", "", map[string]any{
|
|
"error": finishErr.Error(),
|
|
}))
|
|
emitTerminal(terminalEvents)
|
|
} else {
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(finishErr)))
|
|
emitTerminal(terminalEvents)
|
|
}
|
|
a.scheduleGoalContinuationNudge(session)
|
|
return true, nil
|
|
}
|
|
normalizer.ApplyAssistantFinalText(appServerTurnFinalAssistantText(finalTurn))
|
|
emitTerminal(appServerTurnTerminalEvents(session, turnID, finalTurn, normalizer))
|
|
a.scheduleGoalContinuationNudge(session)
|
|
return true, nil
|
|
}
|
|
terminalEvents := []activityshared.Event{}
|
|
if notice := appServerGoalNoticeEvent(session, turnID, method, result); notice != nil {
|
|
terminalEvents = append(terminalEvents, *notice)
|
|
}
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnCompleted, turnID, SessionStatusReady, "", "", map[string]any{
|
|
"stopReason": "end_turn",
|
|
}))
|
|
emitTerminal(terminalEvents)
|
|
return true, nil
|
|
case appServerSlashReview:
|
|
return a.execReviewSlashCommand(
|
|
ctx,
|
|
appSession,
|
|
session,
|
|
args,
|
|
turnID,
|
|
appTurn,
|
|
normalizer,
|
|
emitEvents,
|
|
emitTerminal,
|
|
emitCommands,
|
|
reportDispatch,
|
|
admitProviderTurn,
|
|
)
|
|
case appServerSlashUndo:
|
|
_, err := appSession.client.ThreadRollback(ctx, map[string]any{
|
|
"threadId": appSession.threadID,
|
|
"numTurns": 1,
|
|
}, a.appServerMessageHandler(appSession, session, turnID, normalizer, emitEvents, emitCommands))
|
|
if err != nil {
|
|
reportCodexDispatchFailure(reportDispatch, err)
|
|
emitTerminal([]activityshared.Event{newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(err))})
|
|
return true, nil
|
|
}
|
|
admitWithoutProviderTurn()
|
|
emitTerminal([]activityshared.Event{
|
|
appServerSystemNoticeEvent(session, turnID, "system_notice", "Removed the last turn from the conversation. Local file changes are not reverted.", ""),
|
|
newTurnActivityEvent(session, EventTurnCompleted, turnID, SessionStatusReady, "", "", map[string]any{
|
|
"stopReason": "end_turn",
|
|
}),
|
|
})
|
|
return true, nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) sessionGoal(agentSessionID string) map[string]any {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
|
|
if appSession == nil {
|
|
return nil
|
|
}
|
|
return clonePayload(appSession.goal)
|
|
}
|
|
|
|
// beginGoalTurnHandoff atomically commits pending ownership, active
|
|
// registration and provider id. Notifications remain buffered while the Turn
|
|
// start event is emitted; drainGoalTurnHandoff then replays them in order.
|
|
func (a *CodexAppServerAdapter) beginGoalTurnHandoff(agentSessionID, providerTurnID string, turn *codexAppServerActiveTurn, identity goalOperationIdentity) bool {
|
|
if a == nil || turn == nil || !identity.valid() {
|
|
return false
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
|
|
if appSession == nil {
|
|
return false
|
|
}
|
|
pending := appSession.pendingGoalTurns[strings.TrimSpace(providerTurnID)]
|
|
if appSession.activeTurn != nil || pending == nil || pending.state != codexGoalTurnPending ||
|
|
appSession.provenanceDegraded || goalIdentityFencedLocked(appSession, identity) {
|
|
return false
|
|
}
|
|
pending.state = codexGoalTurnAdopting
|
|
appSession.activeTurn = turn
|
|
appSession.activeTurnID = strings.TrimSpace(providerTurnID)
|
|
turn.goalIdentity = identity
|
|
turn.goalProvenance = strings.TrimSpace(pending.provenanceMode)
|
|
if turn.goalProvenance == "" {
|
|
turn.goalProvenance = "turn_scoped_goal_update"
|
|
}
|
|
// Main's provider-turn lifecycle is emitted from appTurn, while Goal
|
|
// provenance settlement matches through the session slot. Bind both sides
|
|
// atomically so an adopted turn never falls back to its local Turn ID when
|
|
// emitting root_provider_turn.completed.
|
|
turn.providerTurnID = appSession.activeTurnID
|
|
appSession.activeTurnStartConfirmed = true
|
|
return true
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) drainGoalTurnHandoff(agentSessionID, providerTurnID string, appTurn *codexAppServerActiveTurn) {
|
|
for {
|
|
a.mu.Lock()
|
|
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
|
|
if appSession == nil {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
pending := appSession.pendingGoalTurns[strings.TrimSpace(providerTurnID)]
|
|
if pending == nil || pending.state != codexGoalTurnAdopting {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
if len(pending.notifications) == 0 {
|
|
delete(appSession.pendingGoalTurns, strings.TrimSpace(providerTurnID))
|
|
// The immutable identity has already been copied onto appTurn. The
|
|
// durable ledger remains the exact historical source, so release this
|
|
// per-Turn working evidence immediately instead of accumulating it for
|
|
// the lifetime of a long-running Goal.
|
|
delete(appSession.goalTurnEvidence, strings.TrimSpace(providerTurnID))
|
|
a.pruneGoalProvenanceLocked(appSession)
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
notifications := append([]acpMessage(nil), pending.notifications...)
|
|
pending.notifications = nil
|
|
client, session := appSession.client, pending.session
|
|
a.mu.Unlock()
|
|
reducer := newCodexAppServerReducer(a)
|
|
for _, notification := range notifications {
|
|
usesNormalizer := appServerNotificationUsesNormalizer(notification.Method)
|
|
if usesNormalizer {
|
|
appTurn.processMu.Lock()
|
|
a.mu.Lock()
|
|
currentSession := a.sessions[strings.TrimSpace(agentSessionID)]
|
|
currentPending := (*codexPendingGoalTurn)(nil)
|
|
if currentSession != nil {
|
|
currentPending = currentSession.pendingGoalTurns[strings.TrimSpace(providerTurnID)]
|
|
}
|
|
stillAdopting := currentPending != nil && currentPending.state == codexGoalTurnAdopting
|
|
a.mu.Unlock()
|
|
if !stillAdopting {
|
|
appTurn.processMu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
reduction := reducer.reduceNotification(client, session, appTurn.turnID, notification, appTurn.normalizer, appTurn.emitCommands, true)
|
|
if len(reduction.Events) > 0 {
|
|
appTurn.emit(reduction.Events)
|
|
}
|
|
if usesNormalizer {
|
|
if a.goalHandoffDrainHook != nil {
|
|
a.goalHandoffDrainHook()
|
|
}
|
|
appTurn.processMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// scheduleGoalContinuationNudge re-sends thread/goal/set {status: active}
|
|
// when a goal turn settled but codex did not auto-start the next turn within
|
|
// the grace window. It is a safety net: the primary continuation driver is
|
|
// codex itself (whose turns are adopted on turn/started).
|
|
func (a *CodexAppServerAdapter) scheduleGoalContinuationNudge(session Session) {
|
|
agentSessionID := session.AgentSessionID
|
|
appSession := a.getSession(agentSessionID)
|
|
if appSession == nil || appSession.client == nil {
|
|
return
|
|
}
|
|
client := appSession.client
|
|
threadID := appSession.threadID
|
|
expectedIdentity := a.goalOperationIdentity(agentSessionID)
|
|
if strings.TrimSpace(asString(a.sessionGoal(agentSessionID)["status"])) != "active" {
|
|
return
|
|
}
|
|
grace := a.goalContinuationGraceWindow
|
|
if grace <= 0 {
|
|
grace = defaultCodexAppServerGoalContinuationGraceWindow
|
|
}
|
|
go func() {
|
|
if err := client.waitForProviderProgress(context.Background(), grace); err != nil {
|
|
return
|
|
}
|
|
appSession.goalMutationMu.Lock()
|
|
defer appSession.goalMutationMu.Unlock()
|
|
if a.sessionActiveTurn(agentSessionID) != nil || a.sessionActiveTurnID(agentSessionID) != "" {
|
|
// Codex already continued (adopted turn) or a user turn is running.
|
|
return
|
|
}
|
|
if current := a.goalOperationIdentity(agentSessionID); current != expectedIdentity {
|
|
// A newer set/clear superseded this timer.
|
|
return
|
|
}
|
|
goal := a.sessionGoal(agentSessionID)
|
|
if strings.TrimSpace(asString(goal["status"])) != "active" {
|
|
return
|
|
}
|
|
// Status-only: never re-send objective. Re-asserting the objective
|
|
// after pause/resume can restart Codex Goal work and prevent the
|
|
// provider from settling status=complete. Generation ownership stays
|
|
// with the durable set/adoption that created the Goal.
|
|
params := map[string]any{
|
|
"threadId": threadID,
|
|
"status": "active",
|
|
}
|
|
slog.Info("agent session app-server goal continuation nudge",
|
|
"event", "agent_session.app_server.goal.continuation_nudge",
|
|
"agent_session_id", agentSessionID,
|
|
)
|
|
nudgeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
a.prepareGoalContinuationClaim(agentSessionID, expectedIdentity)
|
|
defer a.clearPreparedGoalContinuationClaim(agentSessionID, expectedIdentity)
|
|
// NoHandler: the continuation turn's notifications must keep flowing
|
|
// to the session-level handler while this RPC is in flight.
|
|
result, err := client.ThreadGoalSetNoHandler(nudgeCtx, params)
|
|
if err != nil {
|
|
slog.Warn("agent session app-server goal continuation nudge failed",
|
|
"event", "agent_session.app_server.goal.continuation_nudge_failed",
|
|
"agent_session_id", agentSessionID,
|
|
"error", err.Error(),
|
|
)
|
|
return
|
|
}
|
|
if nextGoal := appServerGoalFromResult(result); len(nextGoal) > 0 {
|
|
// The RPC may be slow; a newer durable command cannot acquire this
|
|
// provider lock until it returns, but retain the result fence for
|
|
// session teardown/replacement and future lock implementations.
|
|
if current := a.goalOperationIdentity(agentSessionID); current == expectedIdentity {
|
|
a.applyGoalUpdate(agentSessionID, nextGoal)
|
|
a.armGoalContinuationClaim(agentSessionID, expectedIdentity)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
type goalOperationIdentity struct {
|
|
operationID string
|
|
revision int64
|
|
repairEpoch int64
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) goalOperationIdentity(agentSessionID string) goalOperationIdentity {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[agentSessionID]
|
|
if appSession == nil {
|
|
return goalOperationIdentity{}
|
|
}
|
|
return goalOperationIdentity{operationID: appSession.goalOperationID, revision: appSession.goalRevision, repairEpoch: appSession.goalRepairEpoch}
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) replaceGoalOperationIdentity(agentSessionID, operationID string, revision int64, repairEpoch int64) (string, int64, int64) {
|
|
if revision <= 0 && strings.TrimSpace(operationID) == "" {
|
|
current := a.goalOperationIdentity(agentSessionID)
|
|
return current.operationID, current.revision, current.repairEpoch
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[agentSessionID]
|
|
if appSession == nil {
|
|
return "", 0, 0
|
|
}
|
|
previousOperationID, previousRevision, previousRepairEpoch := appSession.goalOperationID, appSession.goalRevision, appSession.goalRepairEpoch
|
|
appSession.goalOperationID, appSession.goalRevision, appSession.goalRepairEpoch = strings.TrimSpace(operationID), revision, repairEpoch
|
|
return previousOperationID, previousRevision, previousRepairEpoch
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) restoreGoalOperationIdentity(agentSessionID, operationID string, revision int64, repairEpoch int64, previousOperationID string, previousRevision int64, previousRepairEpoch int64) {
|
|
if revision <= 0 && strings.TrimSpace(operationID) == "" {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[agentSessionID]
|
|
if appSession == nil || appSession.goalOperationID != strings.TrimSpace(operationID) || appSession.goalRevision != revision || appSession.goalRepairEpoch != repairEpoch {
|
|
return
|
|
}
|
|
appSession.goalOperationID, appSession.goalRevision, appSession.goalRepairEpoch = previousOperationID, previousRevision, previousRepairEpoch
|
|
}
|