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
487 lines
17 KiB
Go
487 lines
17 KiB
Go
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(),
|
|
)
|
|
}
|
|
}()
|
|
}
|