源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user