源码快照 @ 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,486 @@
|
||||
package agentruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
||||
)
|
||||
|
||||
func (a *CodexAppServerAdapter) Cancel(ctx context.Context, session Session, reason string) ([]activityshared.Event, error) {
|
||||
reason = strings.TrimSpace(reason)
|
||||
appSession := a.getSession(session.AgentSessionID)
|
||||
if appSession == nil || appSession.client == nil {
|
||||
return nil, ErrSessionDisconnected
|
||||
}
|
||||
if activeTurn := a.sessionActiveTurn(session.AgentSessionID); activeTurn != nil {
|
||||
a.markRootTurnCanceled(session.AgentSessionID, activeTurn.turnID)
|
||||
}
|
||||
activeTurnID, queued := a.requestActiveTurnCancel(session.AgentSessionID)
|
||||
// Unblock any handler waiting on an approval answer first: the message
|
||||
// read loop is parked inside that handler, so the interrupt response
|
||||
// could never be dispatched otherwise.
|
||||
a.rejectPendingRequests(session.AgentSessionID, errPermissionRequestCanceled)
|
||||
// Stop pauses an active goal BEFORE interrupting the turn: otherwise codex
|
||||
// would immediately auto-start the next goal turn and the stop would be a
|
||||
// no-op from the user's perspective. Pause failures fall through to the
|
||||
// interrupt (the reducer additionally interrupts unowned turns for paused
|
||||
// goals as defense-in-depth).
|
||||
events := a.pauseActiveGoalForCancel(session)
|
||||
if activeTurnID == "" {
|
||||
if queued {
|
||||
return events, nil
|
||||
}
|
||||
return nil, ErrSessionNoActiveTurn
|
||||
}
|
||||
appTurn := a.sessionActiveTurn(session.AgentSessionID)
|
||||
return events, a.interruptActiveTurn(ctx, appSession, session, appTurn, activeTurnID, reason)
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) CancelTargets(ctx context.Context, rootSession Session, targets []CancelTarget, reason string) (TargetedCancelResult, error) {
|
||||
rootRequested := false
|
||||
var rootTarget CancelTarget
|
||||
childTargets := make([]CancelTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if strings.TrimSpace(target.AgentSessionID) == strings.TrimSpace(rootSession.AgentSessionID) {
|
||||
rootRequested = true
|
||||
rootTarget = target
|
||||
continue
|
||||
}
|
||||
childTargets = append(childTargets, target)
|
||||
}
|
||||
if rootRequested {
|
||||
a.markRootTurnCanceled(rootSession.AgentSessionID, rootTarget.TurnID)
|
||||
}
|
||||
// Stop the root provider turn before waiting for child RPCs. A slow child
|
||||
// interrupt must not leave the root alive long enough to launch more
|
||||
// provider-native children after the user's cancellation. Normal root
|
||||
// interruption keeps the app-server connection and child handles alive;
|
||||
// force-close already terminates the whole provider process.
|
||||
var rootEvents []activityshared.Event
|
||||
var rootErr error
|
||||
if rootRequested {
|
||||
rootEvents, rootErr = a.Cancel(ctx, rootSession, reason)
|
||||
}
|
||||
events, confirmedChildren := a.interruptTargetedChildTurns(rootSession, childTargets, reason)
|
||||
result := TargetedCancelResult{Events: events, ConfirmedTargets: confirmedChildren}
|
||||
if rootRequested {
|
||||
if rootErr != nil && !errors.Is(rootErr, ErrSessionNoActiveTurn) {
|
||||
result.Events = append(result.Events, rootEvents...)
|
||||
return result, rootErr
|
||||
}
|
||||
result.Events = append(result.Events, rootEvents...)
|
||||
if rootErr == nil {
|
||||
result.ConfirmedTargets = append(result.ConfirmedTargets, rootTarget)
|
||||
}
|
||||
if rootErr == nil || len(result.ConfirmedTargets) > 0 {
|
||||
return result, nil
|
||||
}
|
||||
return result, rootErr
|
||||
}
|
||||
if len(result.ConfirmedTargets) > 0 {
|
||||
return result, nil
|
||||
}
|
||||
return result, ErrSessionNoActiveTurn
|
||||
}
|
||||
|
||||
// pauseActiveGoalForCancel sets an active goal to paused so codex stops
|
||||
// auto-continuing after the interrupted turn. Best-effort: on failure the
|
||||
// interrupt still proceeds and the reducer's paused-goal defense interrupts
|
||||
// any turn codex starts anyway.
|
||||
func (a *CodexAppServerAdapter) pauseActiveGoalForCancel(session Session) []activityshared.Event {
|
||||
agentSessionID := session.AgentSessionID
|
||||
appSession := a.getSession(agentSessionID)
|
||||
if appSession == nil || appSession.client == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(asString(a.sessionGoal(agentSessionID)["status"])) != "active" {
|
||||
return nil
|
||||
}
|
||||
client := appSession.client
|
||||
// Cancel's own ctx can be short-lived; bound the pause independently.
|
||||
// NoHandler: the turn being canceled is still streaming, so this RPC must
|
||||
// not claim the message handler slot (or serialize behind turn/start).
|
||||
pauseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
result, err := client.ThreadGoalSetNoHandler(pauseCtx, map[string]any{
|
||||
"threadId": appSession.threadID,
|
||||
"status": "paused",
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("agent session app-server goal pause on cancel failed",
|
||||
"event", "agent_session.app_server.goal.pause_failed",
|
||||
"agent_session_id", agentSessionID,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if goal := appServerGoalFromResult(result); len(goal) > 0 {
|
||||
a.applyGoalUpdate(agentSessionID, goal)
|
||||
} else if goal := a.sessionGoal(agentSessionID); len(goal) > 0 {
|
||||
// Status-only set may return an empty goal payload; mirror the pause
|
||||
// locally so the reducer's paused-goal defense engages.
|
||||
goal["status"] = "paused"
|
||||
a.applyGoalUpdate(agentSessionID, goal)
|
||||
}
|
||||
// No transcript notice: pausing is a deliberate action and the banner
|
||||
// already reflects the paused state; repeated stops would spam the
|
||||
// timeline.
|
||||
events := []activityshared.Event{}
|
||||
if event, ok := normalizedGoalUpdatedEvent(session, "thread_goal_update"); ok {
|
||||
events = append(events, event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// interruptActiveTurn stops the active turn. It first asks codex to cancel
|
||||
// gracefully via turn/interrupt; if the turn has not terminated within the
|
||||
// grace window it force-closes the app-server process so the turn can never
|
||||
// hang forever. It returns only after the turn has actually terminated
|
||||
// (terminate-then-respond), so the caller's UI can clear its stopping state
|
||||
// against a real outcome.
|
||||
func (a *CodexAppServerAdapter) interruptActiveTurn(
|
||||
ctx context.Context,
|
||||
appSession *codexAppServerSession,
|
||||
session Session,
|
||||
appTurn *codexAppServerActiveTurn,
|
||||
activeTurnID string,
|
||||
reason string,
|
||||
) error {
|
||||
// Best-effort graceful interrupt, sent asynchronously: a wedged codex may
|
||||
// never answer turn/interrupt, and a synchronous call would burn the whole
|
||||
// acpPermissionModeTimeout before the grace window even starts. Firing it in
|
||||
// the background keeps time-to-force bounded by cancelGraceWindow.
|
||||
go a.sendTurnInterrupt(appSession, session, activeTurnID, reason)
|
||||
|
||||
// Without a turn handle we cannot wait for termination or force-close.
|
||||
if appTurn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
grace := a.cancelGraceWindow
|
||||
if grace <= 0 {
|
||||
grace = defaultCodexAppServerCancelGraceWindow
|
||||
}
|
||||
timer := time.NewTimer(grace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-appTurn.terminated:
|
||||
// codex honored the interrupt; the turn finished gracefully.
|
||||
return nil
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
// codex did not stop in time — force-close the app-server process. The torn
|
||||
// down connection unblocks awaitTurnCompletion via client.Done(), and the
|
||||
// force-canceled flag makes Exec surface the outcome as canceled.
|
||||
a.markTurnForceCanceled(appTurn)
|
||||
slog.Warn("agent session app-server force-closing wedged turn",
|
||||
"event", "agent_session.app_server.interrupt.forced",
|
||||
"agent_session_id", session.AgentSessionID,
|
||||
"provider_session_id", appSession.threadID,
|
||||
"turn_id", activeTurnID,
|
||||
"reason", reason,
|
||||
"grace_ms", grace.Milliseconds(),
|
||||
)
|
||||
_ = appSession.client.Close()
|
||||
|
||||
// Wait for the turn goroutine to finalize so we respond after it stopped.
|
||||
select {
|
||||
case <-appTurn.terminated:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendTurnInterrupt issues a best-effort turn/interrupt. It is called in the
|
||||
// background so a hung interrupt RPC cannot delay the force-close grace window.
|
||||
func (a *CodexAppServerAdapter) sendTurnInterrupt(
|
||||
appSession *codexAppServerSession,
|
||||
session Session,
|
||||
activeTurnID string,
|
||||
reason string,
|
||||
) {
|
||||
a.sendThreadInterrupt(appSession.client, session, appSession.threadID, activeTurnID, reason)
|
||||
}
|
||||
|
||||
// scheduleChildNicknameFetches resolves spawned child sessions' display names.
|
||||
// codex assigns each spawned agent an agentNickname on its Thread object but
|
||||
// never pushes it (no thread/name/updated for children), so we fetch it
|
||||
// asynchronously via thread/read and update the child session title.
|
||||
func (a *CodexAppServerAdapter) scheduleChildNicknameFetches(session Session, childThreadIDs []string) {
|
||||
if a == nil || len(childThreadIDs) == 0 {
|
||||
return
|
||||
}
|
||||
appSession := a.getSession(session.AgentSessionID)
|
||||
if appSession == nil || appSession.client == nil {
|
||||
return
|
||||
}
|
||||
client := appSession.client
|
||||
for _, childThreadID := range childThreadIDs {
|
||||
go a.fetchChildThreadNickname(client, session, childThreadID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) fetchChildThreadNickname(client *codexAppServerClient, session Session, childThreadID string) {
|
||||
childThreadID = strings.TrimSpace(childThreadID)
|
||||
if client == nil || childThreadID == "" {
|
||||
return
|
||||
}
|
||||
// The nickname can be assigned slightly after the spawn item announces the
|
||||
// thread; retry a few times before giving up (numbered fallback remains).
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-client.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), acpStartCallTimeout)
|
||||
raw, err := client.ThreadReadNoHandler(ctx, acpStartCallTimeout, map[string]any{
|
||||
"threadId": childThreadID,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result := map[string]any{}
|
||||
_ = json.Unmarshal(raw, &result)
|
||||
thread := payloadObject(result["thread"])
|
||||
nickname := firstNonEmpty(asString(thread["agentNickname"]), asString(thread["name"]))
|
||||
if nickname == "" {
|
||||
continue
|
||||
}
|
||||
child, ok := a.appServerChildThread(session.AgentSessionID, childThreadID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
childSession := appServerChildSession(session, childThreadID, child)
|
||||
eventCtx, ok := appServerChildEventContext(childSession, child, "child-session-title:"+child.agentSessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventCtx.Title = nickname
|
||||
event := activityshared.NewSessionTitleUpdated(eventCtx)
|
||||
a.emitSessionEvents(session.AgentSessionID, []activityshared.Event{event})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (*CodexAppServerAdapter) sendThreadInterrupt(
|
||||
client *codexAppServerClient,
|
||||
session Session,
|
||||
threadID string,
|
||||
turnID string,
|
||||
reason string,
|
||||
) bool {
|
||||
threadID = strings.TrimSpace(threadID)
|
||||
if client == nil || threadID == "" {
|
||||
return false
|
||||
}
|
||||
turnID = strings.TrimSpace(turnID)
|
||||
err := codexSendTurnInterruptOnce(client, threadID, turnID)
|
||||
if err != nil {
|
||||
// Our own turn bookkeeping settles a turn locally as soon as its Go
|
||||
// context is canceled (see Cancel/interruptActiveTurn), without
|
||||
// waiting for the app-server to actually confirm the turn stopped.
|
||||
// When a slow-to-terminate tool call (for example wait_agent on
|
||||
// several dispatched sub-agents) keeps the app-server's real turn
|
||||
// alive past that point, a subsequent interrupt aimed at the turn id
|
||||
// we *think* is active gets rejected with "expected active turn id X
|
||||
// but found Y". Retry once against Y so the real, still-running turn
|
||||
// actually gets interrupted instead of being abandoned to die on its
|
||||
// own — which otherwise can leave it running for minutes.
|
||||
if foundTurnID, ok := codexExpectedActiveTurnIDMismatch(err); ok && foundTurnID != turnID {
|
||||
slog.Warn("agent session app-server interrupt turn id stale, retrying",
|
||||
"event", "agent_session.app_server.interrupt.turn_id_stale",
|
||||
"agent_session_id", session.AgentSessionID,
|
||||
"provider_session_id", threadID,
|
||||
"requested_turn_id", turnID,
|
||||
"actual_turn_id", foundTurnID,
|
||||
"reason", reason,
|
||||
)
|
||||
err = codexSendTurnInterruptOnce(client, threadID, foundTurnID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("agent session app-server interrupt failed",
|
||||
"event", "agent_session.app_server.interrupt.failed",
|
||||
"agent_session_id", session.AgentSessionID,
|
||||
"provider_session_id", threadID,
|
||||
"turn_id", turnID,
|
||||
"reason", reason,
|
||||
"error", err.Error(),
|
||||
)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func codexSendTurnInterruptOnce(client *codexAppServerClient, threadID, turnID string) error {
|
||||
interruptCtx, cancel := context.WithTimeout(context.Background(), acpPermissionModeTimeout)
|
||||
defer cancel()
|
||||
_, err := client.TurnInterruptNoHandler(interruptCtx, acpPermissionModeTimeout, map[string]any{
|
||||
"threadId": threadID,
|
||||
"turnId": turnID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// codexExpectedActiveTurnIDMismatch recognizes the codex app-server's
|
||||
// turn/interrupt rejection for a stale expected turn id: "expected active
|
||||
// turn id <requested> but found <actual>". It reports actual so the caller
|
||||
// can retry against the turn codex itself considers active. JSON-RPC -32600
|
||||
// is the generic "invalid request" code the app-server reuses for several
|
||||
// distinct rejections (see isACPProviderSessionNotFound), so this keys off
|
||||
// the distinctive message text rather than the code.
|
||||
func codexExpectedActiveTurnIDMismatch(err error) (string, bool) {
|
||||
var callErr *acpCallError
|
||||
if !errors.As(err, &callErr) || callErr == nil {
|
||||
return "", false
|
||||
}
|
||||
message := strings.TrimSpace(callErr.Err.Message)
|
||||
lower := strings.ToLower(message)
|
||||
if !strings.Contains(lower, "expected active turn id") {
|
||||
return "", false
|
||||
}
|
||||
const marker = "but found "
|
||||
idx := strings.LastIndex(lower, marker)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(message[idx+len(marker):])
|
||||
if sp := strings.IndexAny(rest, " \t\n"); sp >= 0 {
|
||||
rest = rest[:sp]
|
||||
}
|
||||
rest = strings.Trim(rest, ".,;")
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) interruptTargetedChildTurns(
|
||||
session Session,
|
||||
targets []CancelTarget,
|
||||
reason string,
|
||||
) ([]activityshared.Event, []CancelTarget) {
|
||||
if a == nil || len(targets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
appSession := a.getSession(session.AgentSessionID)
|
||||
if appSession == nil || appSession.client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events := make([]activityshared.Event, 0, len(targets))
|
||||
type confirmation struct {
|
||||
target CancelTarget
|
||||
confirmed bool
|
||||
}
|
||||
confirmations := make(chan confirmation, len(targets))
|
||||
found := 0
|
||||
for _, target := range targets {
|
||||
childThreadID, context, ok := a.appServerChildThreadByAgentSessionID(session.AgentSessionID, target.AgentSessionID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
go func(target CancelTarget, childThreadID string) {
|
||||
confirmations <- confirmation{
|
||||
target: target,
|
||||
confirmed: a.sendThreadInterrupt(appSession.client, session, childThreadID, "", reason),
|
||||
}
|
||||
}(target, childThreadID)
|
||||
childSession := appServerChildSession(session, childThreadID, context)
|
||||
eventContext, ok := appServerChildEventContext(childSession, context, "child-turn-cancel-requested:"+context.turnID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
event := activityshared.NewTurnUpdated(eventContext, context.turnID, activityshared.TurnPhaseWaiting)
|
||||
event.Payload.Metadata = map[string]any{"cancelRequested": true}
|
||||
events = append(events, event)
|
||||
}
|
||||
confirmed := make([]CancelTarget, 0, found)
|
||||
for range found {
|
||||
confirmation := <-confirmations
|
||||
if confirmation.confirmed {
|
||||
confirmed = append(confirmed, confirmation.target)
|
||||
}
|
||||
}
|
||||
return events, confirmed
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) appServerChildThreadByAgentSessionID(rootAgentSessionID string, agentSessionID string) (string, *codexAppServerThreadContext, bool) {
|
||||
if a == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
appSession := a.sessions[strings.TrimSpace(rootAgentSessionID)]
|
||||
if appSession == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
for childThreadID, child := range appSession.childThreads {
|
||||
if child != nil && strings.TrimSpace(child.agentSessionID) == strings.TrimSpace(agentSessionID) {
|
||||
copy := *child
|
||||
return childThreadID, ©, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) markTurnForceCanceled(turn *codexAppServerActiveTurn) {
|
||||
if a == nil || turn == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
turn.forceCanceled = true
|
||||
agentSessionID := turn.session.AgentSessionID
|
||||
emits := turn.settleEmits && !turn.phase.terminal()
|
||||
turn.phase = codexAppServerTurnPhaseCanceled
|
||||
a.mu.Unlock()
|
||||
terminal := codexAppServerTurnTerminal{err: errPermissionRequestCanceled, phase: codexAppServerTurnPhaseCanceled}
|
||||
select {
|
||||
case turn.terminal <- terminal:
|
||||
default:
|
||||
}
|
||||
// Force-cancel is a first-class terminal transition: finalize from here
|
||||
// so terminal events do not depend on the (possibly wedged) shell.
|
||||
if emits {
|
||||
a.finalizeSettledTurn(agentSessionID, turn, terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) turnForceCanceled(turn *codexAppServerActiveTurn) bool {
|
||||
if a == nil || turn == nil {
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return turn.forceCanceled
|
||||
}
|
||||
|
||||
func (a *CodexAppServerAdapter) interruptActiveTurnAsync(
|
||||
appSession *codexAppServerSession,
|
||||
session Session,
|
||||
appTurn *codexAppServerActiveTurn,
|
||||
activeTurnID string,
|
||||
reason string,
|
||||
) {
|
||||
go func() {
|
||||
if err := a.interruptActiveTurn(context.Background(), appSession, session, appTurn, activeTurnID, reason); err != nil {
|
||||
slog.Warn("agent session app-server queued interrupt failed",
|
||||
"event", "agent_session.app_server.interrupt.queued_failed",
|
||||
"agent_session_id", session.AgentSessionID,
|
||||
"provider_session_id", appSession.threadID,
|
||||
"turn_id", activeTurnID,
|
||||
"reason", reason,
|
||||
"error", err.Error(),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user