Files
tutti/packages/agent/daemon/runtime/codex_appserver_turn.go
T
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

840 lines
28 KiB
Go

package agentruntime
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func (a *CodexAppServerAdapter) Exec(
ctx context.Context,
session Session,
content []PromptContentBlock,
displayPrompt string,
turnID string,
emit EventSink,
emitCommands CommandSnapshotSink,
) ([]activityshared.Event, error) {
return a.execBlocking(
ctx,
session,
content,
displayPrompt,
turnID,
emit,
emitCommands,
codexTurnExecOptions{},
)
}
func (a *CodexAppServerAdapter) ExecWithProviderAcceptance(
ctx context.Context,
session Session,
content []PromptContentBlock,
displayPrompt string,
turnID string,
emit EventSink,
emitCommands CommandSnapshotSink,
reportDispatch ProviderDispatchSink,
acceptProviderTurn ProviderAcceptanceBarrier,
) ([]activityshared.Event, error) {
return a.execBlocking(
ctx,
session,
content,
displayPrompt,
turnID,
emit,
emitCommands,
codexTurnExecOptions{
reportDispatch: reportDispatch,
acceptProviderTurn: acceptProviderTurn,
},
)
}
func (a *CodexAppServerAdapter) ExecAsync(
ctx context.Context,
session Session,
content []PromptContentBlock,
displayPrompt string,
turnID string,
emit EventSink,
emitCommands CommandSnapshotSink,
) error {
return a.execAsync(ctx, session, content, displayPrompt, turnID, emit, emitCommands, nil)
}
func (a *CodexAppServerAdapter) ExecHistoryReplacement(
ctx context.Context,
session Session,
input HistoryReplacementExecInput,
emit EventSink,
emitCommands CommandSnapshotSink,
reportDispatch ProviderDispatchSink,
) ([]activityshared.Event, error) {
return a.execBlocking(
ctx,
session,
input.Content,
input.DisplayPrompt,
input.TurnID,
emit,
emitCommands,
codexTurnExecOptions{
historyReplacement: true,
reportDispatch: reportDispatch,
},
)
}
type codexTurnExecOptions struct {
historyReplacement bool
reportDispatch ProviderDispatchSink
acceptProviderTurn ProviderAcceptanceBarrier
continuation *codexGuidanceContinuationAdmission
}
func (options codexTurnExecOptions) report(result ProviderDispatchResult) {
if options.reportDispatch != nil {
options.reportDispatch(result)
}
}
func (a *CodexAppServerAdapter) GuideActiveTurn(
ctx context.Context,
session Session,
content []PromptContentBlock,
displayPrompt string,
turnID string,
emit EventSink,
emitCommands CommandSnapshotSink,
) ([]activityshared.Event, error) {
return a.GuideActiveTurnWithProviderDispatch(
ctx,
session,
content,
displayPrompt,
turnID,
emit,
emitCommands,
nil,
)
}
func (a *CodexAppServerAdapter) GuideActiveTurnWithProviderDispatch(
ctx context.Context,
session Session,
content []PromptContentBlock,
displayPrompt string,
turnID string,
emit EventSink,
emitCommands CommandSnapshotSink,
reportDispatch ProviderDispatchSink,
) ([]activityshared.Event, error) {
reportNotDispatched := func() {
if reportDispatch != nil {
reportDispatch(ProviderDispatchResult{
Disposition: DispatchDispositionNotDispatched,
})
}
}
appSession := a.getSession(session.AgentSessionID)
if appSession == nil || appSession.client == nil {
reportNotDispatched()
return nil, ErrSessionDisconnected
}
activeTurnID := a.sessionActiveTurnID(session.AgentSessionID)
session.ProviderSessionID = appSession.threadID
explicitDisplayPrompt, visibleText := explicitAndVisiblePromptText(content, displayPrompt)
mentionRoutingApplied, mentionRoutingSkills := tuttiMentionRoutingSkills(visibleText)
if activeTurnID != "" {
providerContent := content
if mentionRoutingApplied {
providerContent = appendTuttiMentionRoutingContent(providerContent, mentionRoutingSkills)
}
var err error
providerContent, err = materializeProviderPromptImagesAtBoundary(ctx, providerContent, a.promptImageMaterializer)
if err != nil {
reportNotDispatched()
return nil, err
}
events, err := a.steerActiveTurn(
ctx, appSession, session, content, providerContent,
explicitDisplayPrompt, visibleText, turnID, activeTurnID, emit,
)
if err != nil {
if reportDispatch != nil {
reportDispatch(ProviderDispatchResult{
Disposition: DispatchDispositionOutcomeUnknown,
})
}
return events, err
}
reportCodexAppliedWithoutProviderTurn(reportDispatch)
return events, nil
}
// The canonical root turn remains active while child sessions drain even
// after the provider's root turn has ended. Guidance in that window starts
// another provider turn on the same root thread and keeps the same
// WorkspaceAgentTurn id.
if a.sessionActiveTurn(session.AgentSessionID) != nil {
// The provider turn exists but turn/started has not supplied its id yet;
// starting another turn would race the existing one.
reportNotDispatched()
return nil, ErrSessionNoActiveTurn
}
events, err := a.startGuidanceContinuation(
ctx,
session,
content,
displayPrompt,
turnID,
emit,
emitCommands,
)
if err != nil {
reportNotDispatched()
return events, err
}
reportCodexAppliedWithoutProviderTurn(reportDispatch)
return events, nil
}
func (appTurn *codexAppServerActiveTurn) markTerminated() {
appTurn.terminatedOnce.Do(func() { close(appTurn.terminated) })
}
// markTurnSettleEmits flips the turn to settle-path terminal emission just
// before the provider turn is submitted, under the adapter mutex so the
// notification loop observes it consistently.
func (a *CodexAppServerAdapter) markTurnSettleEmits(appTurn *codexAppServerActiveTurn) {
if a == nil || appTurn == nil {
return
}
a.mu.Lock()
appTurn.settleEmits = true
a.mu.Unlock()
}
// finalizeSettledTurn produces the settled turn's terminal events from the
// notification path, releases the active-turn slot, and signals terminated —
// terminal production no longer depends on a parked goroutine (ADR 0005 C).
// Classification mirrors the blocking shell exactly; the shell's turnClosed
// guard drops any duplicate.
func (a *CodexAppServerAdapter) finalizeSettledTurn(agentSessionID string, appTurn *codexAppServerActiveTurn, terminal codexAppServerTurnTerminal) {
if a == nil || appTurn == nil || appTurn.emitTerminal == nil {
return
}
providerTurnID := firstNonEmpty(asString(terminal.turn["id"]), appTurn.providerTurnID)
appTurn.diagnostics.Finish(string(terminal.phase), providerTurnID)
appTurn.processMu.Lock()
appTurn.settleFinalized.Store(true)
session := appTurn.session
turnID := appTurn.turnID
if terminal.err != nil {
if errors.Is(terminal.err, context.Canceled) ||
errors.Is(terminal.err, errPermissionRequestCanceled) ||
a.turnForceCanceled(appTurn) ||
terminal.phase == codexAppServerTurnPhaseCanceled {
terminalEvents := a.pendingRequestFailureEvents(session, turnID, errPermissionRequestCanceled)
terminalEvents = append(terminalEvents, appTurn.normalizer.FinishInterrupted(session, turnID, "interrupted")...)
terminalEvents = append(terminalEvents, appServerRootProviderTurnCompletedEvent(
session,
turnID,
appTurn.providerTurnID,
activityshared.TurnOutcomeCanceled,
map[string]any{"error": terminal.err.Error()},
))
terminalEvents = stampProviderInputUnitFromError(terminal.err, terminalEvents)
appTurn.emitTerminal(terminalEvents)
} else {
terminalEvents := appTurn.normalizer.FinishFailed(session, turnID)
terminalEvents = append(terminalEvents, appServerRootProviderTurnCompletedEvent(
session,
turnID,
appTurn.providerTurnID,
activityshared.TurnOutcomeFailed,
acpFailureMetadata(terminal.err),
))
terminalEvents = stampProviderInputUnitFromError(terminal.err, terminalEvents)
appTurn.emitTerminal(terminalEvents)
}
} else {
appTurn.normalizer.ApplyAssistantFinalText(appServerTurnFinalAssistantText(terminal.turn))
appTurn.emitTerminal(appServerTurnTerminalEvents(session, turnID, terminal.turn, appTurn.normalizer))
}
appTurn.processMu.Unlock()
a.endActiveTurn(agentSessionID, appTurn)
appTurn.markTerminated()
if appTurn.kind == codexAppServerTurnKindGoalAdopted && appTurn.goalIdentity.valid() {
a.armGoalContinuationClaim(agentSessionID, appTurn.goalIdentity)
}
// With an active goal, codex normally auto-starts the next turn; the
// nudge covers the case where it does not. This must run regardless of
// how the turn settled: a mid-goal turn can end failed (a transient tool
// or model error) or externally canceled (client hiccup) while codex's
// own thread state still reports the goal active, and if codex does not
// resume on its own the goal would otherwise stop advancing for good
// with no further signal. scheduleGoalContinuationNudge already no-ops
// once the goal itself is no longer active (paused/complete/cleared) or
// the app-server connection is gone, so calling it unconditionally here
// cannot resume a goal that was legitimately stopped (for example Cancel
// pauses the goal before interrupting the turn, so a user-initiated
// cancellation settles with the goal already paused).
a.scheduleGoalContinuationNudge(session)
}
// settleTurnExternal settles THIS turn (pointer match) from an external
// death signal — context cancellation or client death — as a first-class
// machine transition instead of a parked-goroutine select arm.
func (a *CodexAppServerAdapter) settleTurnExternal(agentSessionID string, appTurn *codexAppServerActiveTurn, terminal codexAppServerTurnTerminal) {
if a == nil || appTurn == nil {
return
}
a.mu.Lock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
settled := false
if appSession != nil && appSession.activeTurn == appTurn && !appTurn.phase.terminal() {
appTurn.phase = terminal.phase
appSession.activeTurnID = ""
appSession.activeTurnStartConfirmed = false
settled = true
}
emits := settled && appTurn.settleEmits
a.mu.Unlock()
if !settled {
return
}
select {
case appTurn.terminal <- terminal:
default:
}
if emits {
a.finalizeSettledTurn(agentSessionID, appTurn, terminal)
}
}
// watchTurnExternalTermination translates external death signals (context
// cancellation, client death) into turn-machine transitions. It exits as
// soon as the turn terminates through any path.
func (a *CodexAppServerAdapter) watchTurnExternalTermination(appSession *codexAppServerSession, appTurn *codexAppServerActiveTurn) {
select {
case <-appTurn.terminated:
case <-appTurn.ctx.Done():
a.settleTurnExternal(appTurn.session.AgentSessionID, appTurn, codexAppServerTurnTerminal{
err: appTurn.ctx.Err(), phase: codexAppServerTurnPhaseCanceled,
})
case <-appSession.client.Done():
err := appSession.client.Err()
if err == nil {
err = ErrSessionDisconnected
}
a.settleTurnExternal(appTurn.session.AgentSessionID, appTurn, codexAppServerTurnTerminal{
err: err, phase: codexAppServerTurnPhaseFailed,
})
}
}
func (a *CodexAppServerAdapter) execBlocking(
ctx context.Context,
session Session,
content []PromptContentBlock,
displayPrompt string,
turnID string,
emit EventSink,
emitCommands CommandSnapshotSink,
options codexTurnExecOptions,
) ([]activityshared.Event, error) {
execMetadata := execMetadataFromContext(ctx)
continuation := options.continuation
recordNotDispatched := func() {
if options.reportDispatch != nil {
options.report(ProviderDispatchResult{
Disposition: DispatchDispositionNotDispatched,
})
}
}
execState, ok := a.snapshotExecState(session.AgentSessionID)
if !ok {
recordNotDispatched()
if continuation != nil {
continuation.admitted <- ErrSessionDisconnected
}
return nil, ErrSessionDisconnected
}
appSession := execState.liveSession
effectiveSettings := codexAppServerEffectiveSettings(
execState.models,
codexAppServerExecSessionWithConfig(session, execState.config),
nil,
)
session.Settings = &effectiveSettings
session.ProviderSessionID = appSession.threadID
explicitDisplayPrompt, visibleText := explicitAndVisiblePromptText(content, displayPrompt)
mentionRoutingApplied, mentionRoutingSkills := tuttiMentionRoutingSkills(visibleText)
if activeTurnID := a.sessionActiveTurnID(session.AgentSessionID); activeTurnID != "" {
if continuation != nil {
continuation.admitted <- ErrSessionActiveTurn
}
if continuation != nil || options.historyReplacement {
recordNotDispatched()
return nil, ErrSessionActiveTurn
}
if command, args := splitSlashCommand(visibleText); command == appServerSlashGoal {
// Goal commands are thread-level control operations; steering them
// would paste "/goal …" into the running turn as prompt text
// instead of executing the RPC.
return a.execGoalControlCommand(ctx, appSession, session, args, turnID, content, explicitDisplayPrompt, visibleText, emit, options.reportDispatch)
}
providerContent := content
if mentionRoutingApplied {
providerContent = appendTuttiMentionRoutingContent(providerContent, mentionRoutingSkills)
}
var err error
providerContent, err = materializeProviderPromptImagesAtBoundary(ctx, providerContent, a.promptImageMaterializer)
if err != nil {
recordNotDispatched()
return nil, err
}
events, err := a.steerActiveTurn(ctx, appSession, session, content, providerContent, explicitDisplayPrompt, visibleText, turnID, activeTurnID, emit)
if err != nil {
reportCodexDispatchFailure(options.reportDispatch, err)
return events, err
}
reportCodexAppliedWithoutProviderTurn(options.reportDispatch)
return events, nil
}
normalizer := newACPTurnNormalizer()
// The emit mutex is held across the sink callback: after `turn/start`
// responds, streaming notifications are emitted from the client read
// loop while this goroutine waits, so emissions must be serialized.
// Terminal events close the turn: anything a handler emits afterwards
// (for example a rejected approval resolving during cancel) is dropped,
// so the turn outcome event is always the last one the controller sees.
var eventsMu sync.Mutex
var events []activityshared.Event
turnClosed := false
providerAccepted := options.acceptProviderTurn == nil
var pendingProviderEvents []activityshared.Event
emitLocked := func(next []activityshared.Event) {
next = a.inputUnits.stamp(session.AgentSessionID, next)
next = a.stampTurnLifecycleSnapshots(session.AgentSessionID, next)
events = append(events, next...)
if emit != nil {
emit(next)
}
}
emitProviderLocked := func(next []activityshared.Event) {
if !providerAccepted {
pendingProviderEvents = append(pendingProviderEvents, next...)
return
}
emitLocked(next)
}
emitEvents := func(next []activityshared.Event) {
if len(next) == 0 {
return
}
eventsMu.Lock()
defer eventsMu.Unlock()
if turnClosed {
return
}
emitProviderLocked(next)
}
emitTerminal := func(next []activityshared.Event) {
eventsMu.Lock()
defer eventsMu.Unlock()
if turnClosed {
return
}
turnClosed = true
if len(next) > 0 {
emitProviderLocked(next)
}
}
// A turn/start failure has no provider identity to accept. Publish its
// canonical terminal directly so the controller can settle the Turn, while
// dropping any provider events that arrived before the failed response.
emitProviderlessTerminal := func(next []activityshared.Event) {
eventsMu.Lock()
defer eventsMu.Unlock()
if turnClosed {
return
}
turnClosed = true
pendingProviderEvents = nil
if len(next) > 0 {
emitLocked(next)
}
}
releaseProviderEvents := func() {
eventsMu.Lock()
defer eventsMu.Unlock()
if providerAccepted {
return
}
providerAccepted = true
if len(pendingProviderEvents) > 0 {
emitLocked(pendingProviderEvents)
pendingProviderEvents = nil
}
}
discardProviderEvents := func() {
eventsMu.Lock()
defer eventsMu.Unlock()
pendingProviderEvents = nil
}
snapshotEvents := func() []activityshared.Event {
eventsMu.Lock()
defer eventsMu.Unlock()
return append([]activityshared.Event(nil), events...)
}
startEvents := []activityshared.Event{
newUserPromptActivityEvent(ctx, session, content, explicitDisplayPrompt, visibleText, turnID, nil),
newTurnActivityEvent(session, EventTurnStarted, turnID, SessionStatusWorking, "", "", nil),
}
appTurn := &codexAppServerActiveTurn{
turnID: turnID,
session: session,
ctx: ctx,
normalizer: normalizer,
diagnostics: newCodexAppServerTurnDiagnostics(nil, turnID),
emit: emitEvents,
emitCommands: emitCommands,
kind: codexAppServerTurnKindNormal,
phase: codexAppServerTurnPhaseRunning,
terminal: make(chan codexAppServerTurnTerminal, 1),
terminated: make(chan struct{}),
}
appTurn.emitTerminal = emitTerminal
admitProviderTurn := func(providerTurnID string) error {
if err := options.confirmProviderTurnAcceptance(appSession.threadID, providerTurnID); err != nil {
discardProviderEvents()
if providerTurnID != "" {
a.interruptActiveTurnAsync(
appSession,
session,
appTurn,
providerTurnID,
"durable acceptance failed",
)
}
return err
}
releaseProviderEvents()
return nil
}
admitWithoutProviderTurn := func() {
reportCodexAppliedWithoutProviderTurn(options.reportDispatch)
releaseProviderEvents()
}
// Signal turn termination once this goroutine returns (after terminal events
// are emitted), so a concurrent Cancel only responds after the turn stopped.
// The settle path may finalize first; the Once keeps the close single.
defer appTurn.markTerminated()
if !a.beginActiveTurn(session.AgentSessionID, appTurn) {
recordNotDispatched()
if continuation != nil {
continuation.admitted <- ErrSessionActiveTurn
}
return nil, ErrSessionActiveTurn
}
defer a.endActiveTurn(session.AgentSessionID, appTurn)
if continuation != nil {
continuation.admitted <- nil
<-continuation.provisionalStarted
}
eventsMu.Lock()
emitLocked(startEvents)
eventsMu.Unlock()
if !options.historyReplacement {
if handled, err := a.execSlashCommand(
ctx,
appSession,
session,
visibleText,
turnID,
appTurn,
normalizer,
emitEvents,
emitTerminal,
emitCommands,
options.reportDispatch,
admitProviderTurn,
admitWithoutProviderTurn,
); handled {
return snapshotEvents(), err
}
}
providerContent := content
if mentionRoutingApplied {
providerContent = appendTuttiMentionRoutingContent(providerContent, mentionRoutingSkills)
}
var err error
providerContent, err = materializeProviderPromptImagesAtBoundary(ctx, providerContent, a.promptImageMaterializer)
if err != nil {
recordNotDispatched()
return snapshotEvents(), err
}
// From here on the settle path (notification loop) owns terminal event
// production; the blocking shell below only waits and returns.
a.markTurnSettleEmits(appTurn)
trace := newCodexAppServerTurnTrace(session, turnID, execMetadata)
appTurn.diagnostics.Start(trace)
tuttiModeHostContext := renderTuttiModeHostContext(
tuttiModeTurnSnapshotFromContext(ctx),
)
a.mu.Lock()
if session.IsSideConversation() {
if strings.TrimSpace(tuttiModeHostContext) == "" {
tuttiModeHostContext = appSession.tuttiModeHostContext
}
} else {
appSession.tuttiModeHostContext = tuttiModeHostContext
}
a.mu.Unlock()
if session.IsSideConversation() {
tuttiModeHostContext = strings.TrimSpace(
tuttiModeHostContext + "\n\n" + codexSideDeveloperInstructions,
)
}
turnParams := appServerTurnStartParams(
session,
appSession.threadID,
providerContent,
appSession.planModeMask,
appSession.defaultModeMask,
execState.defaultModel,
tuttiModeHostContext,
a.config.commandNetworkAccess,
)
if clientUserMessageID := metadataString(execMetadata, "clientSubmitId"); clientUserMessageID != "" {
// clientSubmitId is an opaque, caller-stable recovery token. The
// canonical Turn id stays Tutti-owned and is never used as Codex client
// identity.
turnParams["clientUserMessageId"] = clientUserMessageID
}
trace.Log("turn.start.params", codexAppServerTraceTurnStartParams(session, turnParams, providerContent))
turnStartAckTimeout := a.turnStartAckTimeout
if turnStartAckTimeout <= 0 {
turnStartAckTimeout = defaultCodexAppServerTurnStartAckTimeout
}
logAgentSubmitTrace("turn.start.requested", session, turnID, execMetadata, map[string]any{
"timeout_ms": turnStartAckTimeout.Milliseconds(),
})
turnStartedAt := time.Now()
result, err := appSession.client.TurnStart(ctx, turnStartAckTimeout, turnParams)
if err != nil {
reportCodexDispatchFailure(options.reportDispatch, err)
durationMS := time.Since(turnStartedAt).Milliseconds()
appTurn.diagnostics.Finish("failed", "")
trace.Log("turn.start.failed", map[string]any{
"duration_ms": durationMS,
"error": err.Error(),
})
logAgentSubmitTrace("turn.start.failed", session, turnID, execMetadata, map[string]any{
"duration_ms": durationMS,
"error": err.Error(),
})
invalidateClient := codexTurnStartClientUnhealthy(err, appSession.client)
a.endActiveTurn(session.AgentSessionID, appTurn)
if errors.Is(err, context.Canceled) || errors.Is(err, errPermissionRequestCanceled) {
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": err.Error(),
}))
emitProviderlessTerminal(terminalEvents)
} else {
terminalEvents := normalizer.FinishFailed(session, turnID)
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(err)))
emitProviderlessTerminal(terminalEvents)
}
if invalidateClient && a.invalidateSessionClient(session.AgentSessionID, appSession.client) {
slog.Warn(
"agent session app-server client invalidated after turn/start failed",
"event", "agent_session.app_server.turn_start.client_invalidated",
"agent_session_id", session.AgentSessionID,
"provider_session_id", appSession.threadID,
"turn_id", turnID,
"error", err.Error(),
)
}
return snapshotEvents(), nil
}
durationMS := time.Since(turnStartedAt).Milliseconds()
trace.Log("turn.start.succeeded", map[string]any{
"duration_ms": durationMS,
"result_size": len(result),
})
logAgentSubmitTrace("turn.start.succeeded", session, turnID, execMetadata, map[string]any{
"duration_ms": durationMS,
"result_size": len(result),
})
// The app-server responds to turn/start immediately with the inProgress
// turn; the real output streams as notifications and the final turn
// arrives with the turn/completed notification.
initialTurn := appServerTurnFromResult(result)
if providerTurnID := asString(initialTurn["id"]); providerTurnID != "" {
if a.setSessionActiveTurnID(session.AgentSessionID, appTurn, providerTurnID) {
a.interruptActiveTurnAsync(appSession, session, appTurn, providerTurnID, "queued cancel")
}
if err := admitProviderTurn(providerTurnID); err != nil {
a.endActiveTurn(session.AgentSessionID, appTurn)
return snapshotEvents(), err
}
} else {
if err := admitProviderTurn(""); err != nil {
a.endActiveTurn(session.AgentSessionID, appTurn)
return snapshotEvents(), err
}
}
go a.watchTurnExternalTermination(appSession, appTurn)
finalTurn, finishErr := a.awaitTurnCompletion(ctx, appSession, appTurn, initialTurn)
// The settle path finalizes AFTER delivering the terminal channel value:
// wait for terminated (closed at the end of finalize) so the snapshot
// includes the terminal events. The timeout is a safety net for any
// settle hole; the shell classification below then covers it.
select {
case <-appTurn.terminated:
case <-time.After(2 * time.Second):
}
a.endActiveTurn(session.AgentSessionID, appTurn)
if appTurn.settleFinalized.Load() {
// The settle path already produced the terminal events.
return snapshotEvents(), nil
}
slog.Warn(
"agent session app-server turn terminal produced by blocking shell (settle shadow miss)",
"agent_session_id", session.AgentSessionID,
"turn_id", turnID,
)
if finishErr != nil {
outcome := "failed"
if errors.Is(finishErr, context.Canceled) || errors.Is(finishErr, errPermissionRequestCanceled) || a.turnForceCanceled(appTurn) {
outcome = "canceled"
}
appTurn.diagnostics.Finish(outcome, appTurn.providerTurnID)
} else {
appTurn.diagnostics.Finish("completed", appTurn.providerTurnID)
}
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, appServerRootProviderTurnCompletedEvent(session, turnID, appTurn.providerTurnID, activityshared.TurnOutcomeCanceled, map[string]any{"error": finishErr.Error()}))
emitTerminal(terminalEvents)
} else {
terminalEvents := normalizer.FinishFailed(session, turnID)
terminalEvents = append(terminalEvents, appServerRootProviderTurnCompletedEvent(session, turnID, appTurn.providerTurnID, activityshared.TurnOutcomeFailed, acpFailureMetadata(finishErr)))
emitTerminal(terminalEvents)
}
return snapshotEvents(), nil
}
normalizer.ApplyAssistantFinalText(appServerTurnFinalAssistantText(finalTurn))
emitTerminal(appServerTurnTerminalEvents(session, turnID, finalTurn, normalizer))
return snapshotEvents(), nil
}
var _ ProviderAcceptanceExecAdapter = (*CodexAppServerAdapter)(nil)
func codexTurnStartClientUnhealthy(err error, client *codexAppServerClient) bool {
if errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, ErrSessionDisconnected) {
return true
}
if client == nil {
return true
}
select {
case <-client.Done():
return true
default:
return false
}
}
// pendingRequestFailureEvents resolves any still-pending approval or
// interactive requests as failed, so a canceled turn does not leave
// dangling approval cards. The handlers waiting on those requests emit
// their own resolution later, but the turn is closed by then and the
// duplicate emission is dropped.
func (a *CodexAppServerAdapter) pendingRequestFailureEvents(
session Session,
turnID string,
cause error,
) []activityshared.Event {
if a == nil {
return nil
}
a.mu.Lock()
appSession := a.sessions[strings.TrimSpace(session.AgentSessionID)]
pendings := make([]*pendingInteractiveRequest, 0)
if appSession != nil {
for _, pending := range appSession.pendingRequests {
pendings = append(pendings, pending)
}
}
a.mu.Unlock()
var events []activityshared.Event
for _, pending := range pendings {
if !pending.supersede(cause) {
continue
}
events = append(events, normalizedPermissionResolvedEvents(session, turnID, pending, pendingInteractiveResponse{}, cause)...)
}
return events
}
// awaitTurnCompletion blocks until the turn finishes. When the turn/start
// (or review/start) response already reports a terminal status it is used
// directly; otherwise the final turn payload comes from the turn/completed
// notification via the active turn context.
func (a *CodexAppServerAdapter) awaitTurnCompletion(
ctx context.Context,
appSession *codexAppServerSession,
appTurn *codexAppServerActiveTurn,
initialTurn map[string]any,
) (map[string]any, error) {
if appServerTurnStatusTerminal(initialTurn) {
a.completeActiveTurn(appTurn.session.AgentSessionID, initialTurn)
}
select {
case terminal := <-appTurn.terminal:
return terminal.turn, terminal.err
case <-ctx.Done():
return nil, ctx.Err()
case <-appSession.client.Done():
err := appSession.client.Err()
if err == nil {
err = ErrSessionDisconnected
}
return nil, err
}
}
func appServerTurnStatusTerminal(turn map[string]any) bool {
switch asString(turn["status"]) {
case "completed", "failed", "interrupted", "canceled":
return true
default:
return false
}
}