Skip to content

feat(fleet): send a live foreground agent to the background with b - #261

Draft
tintinweb wants to merge 1 commit into
masterfrom
feat/send-foreground-to-background
Draft

feat(fleet): send a live foreground agent to the background with b#261
tintinweb wants to merge 1 commit into
masterfrom
feat/send-foreground-to-background

Conversation

@tintinweb

@tintinweb tintinweb commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Supersedes #233 (thanks @titouanmathis) — same feature, rebuilt against current master. #229 stays open and is not addressed here.

Left out of #233 deliberately: the foregroundTimeoutMs auto-detach setting, and a bundled shutdown/join-suppression rework. Reasoning in Behavior and compatibility and Related work.

Summary

A foreground Agent call blocks the parent for as long as the child runs. Once it is running, the only exits are to keep waiting or to kill it — there is no way to say "keep going, I'll read it later" without losing the work already done.

Three distinct failure modes today:

  • Sunk work. Stopping a slow agent discards a partial run; restarting it in the background re-pays the whole context and tool cost from zero.
  • The parent is pinned. Nothing else can happen in the session while the call blocks, even when the result stops being the next thing the user needs.
  • Misjudged calls are unrecoverable. run_in_background: false is a guess made before the agent starts. When it turns out wrong, there is no correction that preserves the run.

What changed

One idea: a run can change which pool owns it and who is waiting on it, without changing the run.

AgentManager.sendToBackground(id) flips a live blocking record to background in place. Session, promise, in-flight turn, abort controller, transcript and worktree are all untouched; the record keeps its id. The caller blocked in spawnAndWait gets control back and the Agent tool returns the agent ID, exactly like a background spawn. The run then settles through the ordinary background path — settleRun already reads the live record.isBackground — so completion, notification and pool release are the existing code, not a second path.

Consequences that needed handling:

  • Eligibility is a registered waiter, not isBackground === false. spawnAndWait now registers a wake-up per call and races it against record.promise; its presence is what proves there is an inline caller to release. The two sets differ, and the gap is where the bugs are: a foreground resume and a detached isBackground: false spawn (RPC, @handle, the registry) are both false with nobody blocked on them. Handing one of those "back" charges a background slot no settle path releases — a concurrency limit permanently lowered — and reports a handoff to a caller that does not exist. The waiter also closes the window where a drained blocking spawn is already "running" but its caller has not yet resumed from await record.startGate.
  • Pool transfer. feat(concurrency): maxConcurrentForeground — a separate, opt-in limit for blocking subagents #260 gave blocking spawns their own pool. startAgent captures pool and hands it to settleRun precisely so a mid-run limit change cannot desync acquire from release. A handoff is a mid-run pool change, so it goes through a closure that updates pool and both counters together — release foreground, acquire background — leaving settleRun's signature and contract intact.
  • Both parent-abort listeners come off. A blocking spawn that waited for a slot carries two: one armed at enqueue by armQueuedAbort, one armed at start. armQueuedAbort previously never removed its own ("detaching would only be tidiness") — no longer true once a run outlives its caller, or that caller's Esc kills an agent it no longer owns. Both are now registered for removal and released together.
  • UI. b in the conversation viewer, offered from FleetView and /agents. Single press, not the two-press confirm x uses: it is one-way but not destructive. canSendToBackground() mirrors the manager's guard so the affordance never renders on a row the manager would refuse. Footer label is b bg, abbreviated because the idle footer is already full at 80 columns.
  • The tool call's finally no longer tears down a running agent. It previously assumed the tool returning meant the agent had finished.

Also fixed, found while rebuilding and independent of the feature: forwardAbortSignal in src/agent-runner.ts called addEventListener on a signal that may already be aborted, so a pre-aborted parent signal never reached the child session and the child ran to completion with its parent gone. AgentManager.spawn guards this at its own layer; the runner did not.

Related work

States as of opening.

# Title State Relation
#233 feat: send foreground agents to background open Superseded. Same design, rebuilt on master; its detachForeground gated on isBackground === false, which a foreground resume also satisfies.
#260 feat(concurrency): maxConcurrentForeground merged Design interaction, not just a rebase — introduced the second pool and record.blocking this builds on.
#237 feat!: run subagents in the background by default (#232) merged Makes a foreground call deliberate, which is why no auto-detach timeout ships here.
#242 bindExtensions() on child sessions has no matching session_shutdown closed #233 bundled shutdown work overlapping this; left out, since manager.dispose() is already async and bounded.
#229 Bound get_subagent_result(wait: true) with timeout_ms open Cited by #233 as related. Not addressed.

Behavior and compatibility

No breaking changes, and no behavior change unless b is pressed. The added machinery is a Promise.race and three Maps in AgentManager; with nothing handed off, spawnAndWait awaits the same promise it always did, settleRun releases the same pool it acquired, and every tool result is byte-identical. No new settings, no defaults changed, no migration.

After a handoff:

  • The blocking Agent call returns Agent sent to background. with the agent ID, and the completion arrives later as the normal background notification.
  • The parent's Esc no longer reaches that agent — deliberate, since the tool call that owned it has returned. /agents → Running agents → x stops it, as it does for any background agent.
  • It gives up its foreground slot (a queued blocking call can start) and takes a background one. If the background pool is full it runs above capacity rather than being suspended; queued background work waits for the count to drop.
  • One-way and single-use. There is no way back to foreground, by design — nothing is blocking on it any more.

What this does not do: no foregroundTimeoutMs or any automatic detach — background is the default since #237, so a foreground call is an explicit "I need this inline", and a timer that overrides it needs its own case. No shutdown or join-suppression changes. Foreground resume is refused, not supported: resume()'s inline path registers no waiter and releases no slot, so handing it off would leak one.

Performance

npm run bench:ab -- master, working tree vs master. The relevant benchmark is the spawn path:

Agent tool — background spawn > general-purpose, run_in_background    182.65us   183.85us   +0.7%
AgentWidget.render — real mix > 3 running / 7 queued / 3 finished     132.33us   131.42us   -0.7%
FleetList.render (per TUI frame) > 100 agents                         181.58us   181.92us   +0.2%
ConversationViewer.render — markdown: assistant > 500 messages         19.653ms   19.620ms   -0.2%

All within the harness's own ~5% noise floor. The *.perf.test.ts operation-count guards run in the normal suite and pass unchanged.

Testing

npm run lint        Checked 136 files. No fixes applied.
npm run typecheck   clean
npm run build       clean
npm run test        88 files | 1530 passed | 4 skipped (1534)
npm run test:e2e    14 files | 65 passed | 4 skipped (69)

21 new tests: 10 in test/foreground-detach.test.ts (manager contract), 3 in test/foreground-detach-wiring.test.ts (the real extension), 4 in test/conversation-viewer.test.ts, 2 in test/fleet-list.test.ts, 2 in test/agent-runner.test.ts (written red before the forwardAbortSignal fix).

Mutation-checked, source line broken and restored each time:

Mutation Result
Drop the waiter guard from sendToBackground red — refuses a foreground RESUME
Skip releasing the foreground slot on handoff red — moves the run between pools
Skip acquiring the background slot on handoff red — same test
Drop releaseParentAbort from sendToBackground red — both Esc tests
Revert armQueuedAbort to a non-removable listener red — the QUEUED-listener test
Drop blocking from the viewer's canSendToBackground red — 2 viewer tests
Make b ignore canSendToBackground red — 2 viewer tests
Remove the tool's background early-return red — the wiring test
Restore the unconditional finally teardown survived, until the wiring test grew an onAgentFinished assertion; then red
Gate sendToBackground on isBackground !== false (the #233 form) survived — see below

Not covered:

  • The blocking !== true check in sendToBackground is unreachable behind the waiter guard, so no test pins it. It is kept as a stated invariant and the docstring says exactly this rather than implying coverage.
  • No manual TUI verification — b has not been pressed in a live session. Every assertion here is automated.
  • No test exercises a handoff of a run under isolation: "worktree". The worktree settle path is untouched, but that is reasoning, not coverage.

@tintinweb tintinweb added feature New feature or request <📍> labels Aug 24, 2026
@tintinweb tintinweb changed the title feat(fleet): send a live foreground agent to the background with b feat(fleet): send a live foreground agent to the background with b Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

<📍> feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant