feat(fleet): send a live foreground agent to the background with b - #261
Draft
tintinweb wants to merge 1 commit into
Draft
feat(fleet): send a live foreground agent to the background with b#261tintinweb wants to merge 1 commit into
b#261tintinweb wants to merge 1 commit into
Conversation
b …b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #233 (thanks @titouanmathis) — same feature, rebuilt against current
master. #229 stays open and is not addressed here.Left out of #233 deliberately: the
foregroundTimeoutMsauto-detach setting, and a bundled shutdown/join-suppression rework. Reasoning in Behavior and compatibility and Related work.Summary
A foreground
Agentcall 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:
run_in_background: falseis 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 inspawnAndWaitgets control back and theAgenttool returns the agent ID, exactly like a background spawn. The run then settles through the ordinary background path —settleRunalready reads the liverecord.isBackground— so completion, notification and pool release are the existing code, not a second path.Consequences that needed handling:
isBackground === false.spawnAndWaitnow registers a wake-up per call and races it againstrecord.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 detachedisBackground: falsespawn (RPC,@handle, the registry) are bothfalsewith 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 fromawait record.startGate.startAgentcapturespooland hands it tosettleRunprecisely 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 updatespooland both counters together — release foreground, acquire background — leavingsettleRun's signature and contract intact.armQueuedAbort, one armed at start.armQueuedAbortpreviously 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.bin the conversation viewer, offered from FleetView and/agents. Single press, not the two-press confirmxuses: 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 isb bg, abbreviated because the idle footer is already full at 80 columns.finallyno 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:
forwardAbortSignalinsrc/agent-runner.tscalledaddEventListeneron 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.spawnguards this at its own layer; the runner did not.Related work
States as of opening.
master; itsdetachForegroundgated onisBackground === false, which a foreground resume also satisfies.record.blockingthis builds on.manager.dispose()is already async and bounded.Behavior and compatibility
No breaking changes, and no behavior change unless
bis pressed. The added machinery is aPromise.raceand threeMaps inAgentManager; with nothing handed off,spawnAndWaitawaits the same promise it always did,settleRunreleases the same pool it acquired, and every tool result is byte-identical. No new settings, no defaults changed, no migration.After a handoff:
Agentcall returnsAgent sent to background.with the agent ID, and the completion arrives later as the normal background notification./agents → Running agents → xstops it, as it does for any background agent.What this does not do: no
foregroundTimeoutMsor 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 vsmaster. The relevant benchmark is the spawn path:All within the harness's own ~5% noise floor. The
*.perf.test.tsoperation-count guards run in the normal suite and pass unchanged.Testing
21 new tests: 10 in
test/foreground-detach.test.ts(manager contract), 3 intest/foreground-detach-wiring.test.ts(the real extension), 4 intest/conversation-viewer.test.ts, 2 intest/fleet-list.test.ts, 2 intest/agent-runner.test.ts(written red before theforwardAbortSignalfix).Mutation-checked, source line broken and restored each time:
sendToBackgroundreleaseParentAbortfromsendToBackgroundarmQueuedAbortto a non-removable listenerblockingfrom the viewer'scanSendToBackgroundbignorecanSendToBackgroundfinallyteardownonAgentFinishedassertion; then redsendToBackgroundonisBackground !== false(the #233 form)Not covered:
blocking !== truecheck insendToBackgroundis 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.bhas not been pressed in a live session. Every assertion here is automated.isolation: "worktree". The worktree settle path is untouched, but that is reasoning, not coverage.