Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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
}