Skip to content

refactor(core): carry typed job outcomes for stops - #46721

Open
kitlangton wants to merge 3 commits into
v2from
job-outcomes
Open

kitlangton wants to merge 3 commits into
v2from
job-outcomes

Conversation

@kitlangton

@kitlangton kitlangton commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Why

Stopping a background shell with Ctrl+D was reported as a failure, waking an idle agent that could then investigate or restart work the user intentionally stopped. Interrupting a foreground subagent likewise collapsed into a generic cancelled tool failure.

This supersedes #46439. Instead of synchronizing a stop reason between Job and its producers, Job carries their typed outcomes. Shell owns process termination; SessionExecution owns the child execution's terminal; Job owns observation and the recoverable notification marker.

What Changes

Action Result
Stop a foreground shell or subagent Successful tool result with status: "stopped" and an instruction not to restart without the user's request. Shell output is preserved.
Stop background work A neutral stopped by user notice is admitted immediately with resume: false; the idle parent reads it at its next step.
Stop a user-entered ! command Its shell message shows stopped by user, and its completion stays quiet.
Cancel a caller after its shell stop was accepted Killing and capture settlement still complete. Overlapping stop callers await the same completion.
Observe a removed or expired shell result Report unavailable, not an invented user stop, exit code, or completion timestamp.
Recover a persisted stopped Job outcome Replay the same quiet notice without rerunning the work.
Upgrade with a completed legacy marker pending Replay its saved output text; do not discard it or infer new outcome fields from rendered text.

The shell.stop endpoint retains the process result and capture; shell.remove still forgets them. The TUI kill action uses shell.stop. Schema, client, and OpenAPI surfaces include the unavailable-result state.

Outcome Ownership

Job status describes observation: completed means the producer returned an outcome, error means its run failed, and cancelled means observation was abandoned. An intentional stop, timeout, or nonzero shell exit belongs in the producer's typed result, not in a separate Job stop-reason field.

flowchart LR
  S[Shell.stop: killed, capture retained] --> O[ShellResult.Outcome]
  X[SessionExecution.resume: user interruption as a value] --> U[SubagentOutcome]
  O --> J[Job result + canonical recovery descriptor]
  U --> J
  J --> F[Foreground tool response]
  J --> B[BackgroundNotice.deliver]
  J --> P[Persisted marker]
  P --> B
  B --> N[Synthetic notice: stopped means resume false]
Loading
  • New subagents admit their initial prompt without waking, then start only through their Job. Existing-child prompts still wake so joining an active Job does not lose steering. Tool- and command-launched subagents share SubagentJob.
  • Live and recovered notices use the Job's original recovery descriptor. Joining calls do not replace its description or agent label.
  • Foreground shell responses render directly from the outcome instead of reconstructing it from output flags. The separate shell-tool completion Deferred is gone.
  • Joiners receive user interruption as a value; shutdown still interrupts them to preserve restart continuity. Mixed interruption/defect causes remain failures rather than becoming successful stop results.
  • The existing per-generation scope identity, notification observer deduplication, marker-before-release ordering, and acknowledgment-after-admission ordering remain intact.

Demo

comparison-outcomes.mp4

Original matched behavior comparison: base 5894e46688 on the left, the initial implementation 93f0807135 on the right. Both use the same deterministic opencode-drive fixture, a real sleep 60 process, and real Ctrl+D through the shell picker. Model responses are simulated; playback is normal speed and aligned on the stop. Before: ! Shell failed and an unsolicited third model request. After: ↳ Shell stopped by user and no further model request.

The reviewed implementation (c40393cedb) was also exercised with a fresh isolated three-flow run: foreground shell, background shell, and user-entered !sleep 60. It finished with four model requests total, two quiet notices pending, and zero running shells. Foreground continuation is expected; background and user-shell stops do not wake the idle parent.

Scope

This PR does not introduce a generic Job stop registry or change the web app's killed-command error card. TUI busy detection still recognizes quiet stop notices through their metadata rather than a new durable scheduling flag.

Recovery limit: Session terminal publication and Job outcome persistence are not one atomic commit. A process death after a child records user interruption but before its Job persists that outcome can leave a running background marker that recovery resumes. The quiet-replay guarantee applies to persisted stopped Job outcomes; stronger cross-record reconciliation is not added here.

Verification

# Each command runs from its named package directory.
# packages/core
bun typecheck
bun run test
bun run build

# packages/tui and packages/client
bun typecheck
bun run test

# packages/server
bun typecheck
bun run test
bun run build

# packages/simulation
bun run build

# packages/schema
bun typecheck

# packages/protocol
bun typecheck
bun run generate
bun run check:generated

# packages/client and packages/www
bun run generate

Local full suites: 5,139 core passed / 39 skipped; 1,196 TUI passed / 4 skipped; 147 client passed; 54 server passed / 3 skipped. Core, Server, and Simulation builds passed, including their TypeScript compiler checks. The unmodified pre-push hook passed all 33 typecheck tasks.

Regression coverage includes cancelled and overlapping stop callers, normal retention eviction without false user intent, literal pre-outcome shell/subagent markers, interruption combined with drain or settlement defects, canonical metadata when joining/backgrounding an existing Job, initial Job ownership before child execution, explicit continuation, quiet live/recovered notices, and unavailable-result rendering in narrow/light and wide/dark TUIs.

@kitlangton
kitlangton force-pushed the job-outcomes branch 2 times, most recently from bd60185 to db02999 Compare September 2, 2026 03:30
Update the coordinator test to the value-based interruption contract, point the TUI fake servers at POST /api/shell/:id/stop, and include stop in the promise client's shell key list.
Complete accepted shell stops despite caller cancellation, distinguish unavailable results from user stops, and preserve mixed failure causes and legacy completion text. Use canonical Job recovery metadata and a single launch path for new subagents, with regression coverage for each boundary.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant