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
840 lines
28 KiB
Go
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
|
|
}
|
|
}
|