fix(task): make async subagent tasks answer honestly, once, in order, and stop - #45482
Open
NamedIdentity wants to merge 89 commits into
Open
fix(task): make async subagent tasks answer honestly, once, in order, and stop#45482NamedIdentity wants to merge 89 commits into
NamedIdentity wants to merge 89 commits into
Conversation
added 30 commits
August 22, 2026 22:35
…its id BackgroundJob files a job under a caller-supplied id, and the task tool passes the child session id. Resuming a task reuses that session, so start() can replace a settled entry with a fresh run under the same id. Both cancellation sweeps list jobs and then cancel them by id. Between those two steps the matched run can settle and a new one take its place, and cancel(id) then interrupts a run the sweep never matched. In SessionRunState.cancelBackgroundJobs that also widens the walk, because pending grows from the metadata of whatever was cancelled, pulling in jobs from outside the requested branch. Expose the registry's existing per-run token as Lifetime, add listExact() and cancelExact() beside list() and cancel(), and use them in SessionRunState.cancelBackgroundJobs and Session.cancelBackgroundJobs. cancelExact() no-ops when the run it names has already been replaced. This leaves the single-snapshot traversal alone; work admitted after the snapshot is still missed, which is a separate fix.
Adds the modules backing coordinated branch cancellation and async task
result delivery:
- session/closure/* cancellation coordinator, branch discovery and
proof, admission leases, mutation ordering,
durable closure records
- session/attachment/* async child observation and result settlement
- session/task-return.ts child outcome classification
- session/toolpart-closure.ts, toolpart-permit.ts
tool part settlement under cancellation
- session/physical-interrupt.ts
exact runner interruption
- background/binder.ts job admission binding
- tool/task-protocol.ts async task guidance for subagents
Supporting type surface lands in core/background-job.ts (exact lifetimes
and invocation handles), core/event.ts, core/session/projector.ts and
session/run-state.ts.
Nothing registers these yet. They are inert until the admission path is
wired, which is a separate change because widening the runner error
channel propagates through prompt and the HTTP handlers as one unit.
Typecheck clean in packages/core and packages/opencode.
Two behaviours land together because the error channel makes them one unit: widening SessionRunState to carry a refusal propagates through SessionPrompt's Interface to the HTTP handlers, so a smaller commit does not build. Refusing new work while a branch closes. Session entry points now take closure admission before the Runner accepts work, so prompt, loop, shell and command can be refused rather than run while a branch is being cancelled. run-state.ts binds each lease to the Runner or shell that now owns it before that work can escape coordinator observation; a misroute fails closed. prompt.ts widens its Interface and the local prompt, loop, shell and runLoop bindings. runLoop raises the refusal because a subtask taking admission inside the loop is refused there rather than at the entry point. Routing abort through the coordinator. Abort now enters branch cancellation instead of cancelling one list of running jobs: it finds work that can still execute, follows current Task relationships back to the selected session, blocks the proven branch, signals active work, and rescans until nothing in scope can continue. Success is returned only after in-scope work has stopped and the branch has been released, so callers can use it as a real boundary before undoing, moving or otherwise changing a session. abort answers a missing session before making the request. The Location gate is fail-closed by design and would refuse a session it cannot validate, turning what should be a plain success into a typed error plus a ticket, a fence and a durable record for work that never existed. A refusal is reported as 409 SessionBusyError: the session cannot accept the request right now and will once closure settles. Session routes document one error per status code, so this reuses the existing type rather than introducing a second 409. Closure failures render through a closed message table, so a field added to the domain error cannot reach a client by default. server.ts registers SessionClosureRunState.node, which assembles the request-borne capabilities closure cannot depend on directly - reaching them through the layer graph instead would close a cycle.
The closure-aware binder refused any background job started without admission. Admission is what fencing opts into, so refusing its absence rejected every job started by a caller that predates it - which is every existing caller. Jobs with no admission now take the same path they would with no authority wired at all, and fencing applies only where a caller supplies it. Restores parity with the pre-change test baseline: background/job, tool/task and session each returned to their prior results.
The task tool decided synchronous or asynchronous execution from a field named `background`, which reads as a placement change rather than a choice about whether the caller waits. Nothing moves anywhere: the subagent runs the same way either way, and the only difference is whether the tool call returns a receipt now or the subagent's result later. Rename the input to `async`. `background` keeps working as a deprecated alias that selects the same behaviour and logs a warning, so callers written against the old field are unaffected; it is no longer advertised in the schema the model sees, because a deprecated alias should stay decodable without being offered as a second way to say the same thing. The job metadata key, the HTTP surface and OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS are unchanged. With the experiment enabled the tool description now carries the async task protocol: results arrive automatically, so polling is unnecessary, and a subagent cannot return its own result while async tasks it started are still outstanding. Task disposition copy follows the input: 'Async task started' / 'Async task updated'. With the experiment off there is no protocol to follow, so the update text stays generic.
A subagent could start async tasks, reach the end of a model turn, and return before their results arrived. The caller then received an early answer followed by stray results it had no turn left to use. A pause at a tool call could also be mistaken for a finished answer. Give each delegated call an attachment scope. The task tool opens one for the child it starts and carries it into the child's prompts; the prompt loop records each completed turn against it, distinguishing a turn that is a finished answer from one that merely paused. The child's result is released only once the async work started during that call has settled, so the caller sees an answer that can take those results into account. Concurrent calls aimed at one task_id now have a single start owner. The first call owns the start; a second either becomes an ordered extension of the run that produced, or is told it collided. It never creates a second lifetime and never shares delivery ownership ambiguously. A tracking failure is not authority to kill healthy work. If the coordination layer loses certainty it degrades: it returns the best output it actually observed and routes through the ordinary parent ingress, rather than stopping a child whose work is still healthy to make its own bookkeeping look complete. Admission is threaded through the same seam. The caller takes its own lease for the invocation it performs, separate from the target's, and an async result takes a continuation lease before its waiter is scheduled. A refusal while a branch is closing now survives to the task tool as a typed failure instead of becoming a defect, so it can be accounted for rather than swallowed. The attachment scope is a capability, not a schema field: it is handed to one caller and never appears on the wire, so generic ingress is unchanged and cannot join a delegated call's turn observation.
Public job ids are reusable. If one run ends and a replacement starts under the same id between a start and its wait, or between a list and a cancel, an id-based operation acts on a run the caller never observed or owned. A waiter can return another invocation's result, and a stale cancellation can stop new work. Give the task tool the physical lifetime and the opaque invocation handle its own start produced, and route every operation through them. The synchronous waiter uses the exact lifetime; the async observer waits on the exact accepted invocation; interrupt-time cancellation cancels the exact lifetime this call started, rather than whichever run currently holds the id. Publication of the armed lifetime goes through a deferred because onPromote is live from registration onward, which is inside startExact, so a promotion observer can run before startExact returns. It must await publication rather than read a cell that may still be empty. A start that failed publishes absence, which releases any waiter honestly: it armed no lifetime to observe. Two cases resolve rather than parking. An attempt that joined an arm already in progress has no lifetime of its own, so it uses the terminal snapshot it already holds; re-reading by id could only find a successor. And waitForPromotionExact reports undefined for a stale or terminal lifetime where the id-based method blocks forever — honest for a direct caller, but wrong inside the race, where a non-promotion winning would read a completed task as having no result. Only an actual promotion may win. The reservation is also released when a task settles synchronously, since no async observer will consume it.
…losure Task teardown called the same full cancellation path a user abort uses. That path sweeps background jobs recursively and then interrupts the runner, and a task finalizer is awaited by the very fiber or job scope the sweep has to quiesce — so tearing a task down could wait on itself. Every await was locally reasonable and the loop still closed. Separate the two. A physical interrupt performs one exact interrupt with no discovery, no view, no record, and no wait on an owning closure operation. Full cancellation stays, and is still what a direct user abort of the child session uses. Which of the two exact forms applies depends on who is asking. Inside the delegated execution being torn down, the caller is the target: awaiting an in-flight interrupt for its own identity would block on a signal that cannot complete until the finalizer returns, so it reports and returns immediately. In the caller's tool fiber it is an independent party, free to adopt an interrupt already in flight and take its result. Lifetime cancellation is routed through the same registry so it dedupes against an interrupt already tearing that lifetime down. Also register the abort listener and then immediately re-check the signal. A signal that fired between the tool starting and the listener attaching was previously missed altogether, leaving the child running after its caller was aborted.
A child can hit context overflow, an output limit, a provider error, cancellation, unconsumed local tools, or a normal stop with no final text. The task tool returned the last text part, or an empty string, so most of those arrived at the caller as an empty successful task. The caller could not tell 'the child returned no text' from 'the child failed' from 'the task tool could not run'. Classify the child's final state after waiting, and render one structured result. A normal final text part is preserved verbatim. Error, cancellation, incomplete output and no-final-text cases get explicit states plus a pointer that the task session is still addressable by task_id, so the caller can inspect or continue it. Failures to admit or run the task tool itself remain tool failures. Classification happens once, inside the run, so the synchronous return and the async callback carry the same structured result instead of each deriving its own. The synchronous path stops re-wrapping that result — the double wrap is what made a failed child look like an empty successful one. Evidence is factual and bounded: established fields only, unknown facts omitted or marked unknown rather than guessed, and long evidence keeps its beginning and end while stating how much was dropped from the middle. Output retained from a terminal run that a later invocation replaced is reported once, as prior output. A completed run has none to report, since its own output is the result, and an empty string stays distinct from absence. A cancelled child is now a result rather than a tool failure, so it can be told apart from the tool being unable to run at all.
…cipant The closure participant registered by the task attachment layer carried a development-tracking identifier in its exported id. Name it for the subsystem it belongs to instead. The id is an in-process registry and proof key used by the closure driver's exchange bookkeeping. It is not written to part metadata or any durable record, and no test asserts the literal, so this is a rename with no behaviour change.
ctrl+b does not move a subagent anywhere. It stops the caller waiting on a Task
that is already running, and the same child continues to completion. Calling
that "background" reads as a placement change and leaves users unsure whether a
second task is about to start.
Every surface that names the action now says async: the keybind description,
the TUI and direct-run command titles, the footer hint, the Task card title,
the shared session UI subtitle, the HTTP endpoint summary and description, and
the experimental flag's entry in the CLI docs.
The wire contract is deliberately unchanged. The command is still
session.background, the endpoint is still POST
/experimental/session/{id}/background, and the part metadata key is still
background. Only what a person reads has changed, so no client breaks.
The endpoint description now states the behaviour rather than implying a move:
it stops waiting for Task subagents that are blocking the session, and the same
running tasks continue asynchronously.
Two footer tests cover the control itself - that session.background keeps its
name and reaches its handler while showing async copy, and that the hint is
absent for a subagent that is already async.
When a Task branch is cancelled the runtime writes a synthetic user message carrying a complete closure record. Both the shared session UI and the public share page filtered it out as ordinary synthetic text, so a stopped branch left behind a Task card and spinner still claiming the dead work was running. Classify that message and render it as its own row instead. isCompleteClosurePair gates strictly on the complete Message/TextPart pair, so a partial or malformed lookalike stays ordinary synthetic data and renders as it did before. session-ui gains closure-record.tsx; Message switches on it ahead of the user/assistant branches, and SessionTurn stops treating a closure record as a human turn when it resolves undo targets and pending turns. packages/web keeps a browser-local parity copy of the classifier because it has no runtime dependency on @opencode-ai/core; the two files differ only by that explanatory header. Share.tsx's inline part filter and legacy v1 user conversion move into share-message.ts and legacy-user-message.ts, where the synthetic-text rule now admits a closure record.
…abort
Two related gaps in the TUI once a Task branch is cancelled.
The closure record the runtime writes is a synthetic user message. The transcript
list rendered it through the ordinary user-message path, message navigation
stopped on it, and undo/redo could select it as the message to revert to - so a
stopped branch left a Task card and spinner claiming dead work was still running,
and undo could target a record rather than a real turn. closure-record.ts
classifies it once; the session route renders it as its own row, and message
navigation, undo/redo targeting and the reverted-message list all skip it.
transcript.ts exports it as a Branch closure section and drops user messages that
would otherwise render as an empty block. taskSpinnerRunning centralises the
spinner rule so an async Task stops spinning when its session goes idle.
Undo and redo previously fired an abort and continued regardless:
if (status?.type !== "idle") await sdk.client.session.abort(...).catch(() => {})
That swallows failure, and reading status before issuing the revert leaves a
window for work to start in between. runAfterSessionBranchAbort requests closure
and runs the dependent revert only when it succeeded, surfacing failure as a
toast instead. The abort is now unconditional: the idle check was the source of
the race, not a safe fast path.
throwOnError is required rather than stylistic - the generated client defaults to
ThrowOnError = false, so a bare session.abort() resolves with an { error } object
on a typed 500 and a void-ed call discards it entirely.
Undo, redo and their cleanup change a session's transcript and working tree. Nothing ordered them against a branch cancellation running on the same session, so a revert could land while cancellation was proving the branch stable and leave that proof describing a transcript that no longer existed. Each of the three now reserves the session before it acts. If cancellation already blocked the branch the revert is refused and the caller is told; if the revert reserved first, cancellation adopts it, waits, and re-reads. Two boundary cases follow from the reservation: Reverting to a branch-closure record is refused. The record states that a branch was stopped; making it the boundary would delete the history it accounts for and leave the effects. It is reported as SessionBoundaryError and rendered as a 400, which the route already declares. Closure records survive cleanup and are never the fallback boundary of a part-level revert. They are synthetic evidence rather than a turn the user can be returned to. assertNotBusy stays ahead of the reservation. It is a read-only precondition, so checking it first keeps the existing BusyError semantics without widening the window between reserving and acting.
Deleting a session, deleting a message or a part, and replacing a persisted part all change the transcript a running branch cancellation is proving stable. None of them was ordered against it, so a delete could land while cancellation was reading the rows it deletes and leave the cancellation's proof describing history that no longer exists. Each now reserves before it acts. Session deletion reserves the whole subtree in one decision so a refusal cannot leave a partially deleted tree. Message and part deletion reserve in the service rather than in the route, so a direct domain or SDK call cannot bypass the ordering; an enclosing reservation that already covers the session passes through instead of taking one per row. Part replacement becomes its own method. updatePart is the writer a live execution uses for parts it is producing, called per streamed chunk, so reserving it would take a reservation per chunk. replacePart is the destructive one and carries the reservation itself. A refusal is reported rather than raised as a defect. It is an expected condition that succeeds once cancellation settles, so the delete-session, delete-part and update-part endpoints now declare SessionBusyError as delete-message already did, and the CLI delete prints why it was refused. Workspace deletion is the one caller that still terminates: an already-gone session is benign, but a session refusing removal because its branch is being cancelled would be orphaned behind a deleted workspace. Session removal keeps working without instance context, where there is no coordinator to reserve with. What that does not establish is that no cancellation is running over those rows elsewhere in the process.
Warp claims a session from the workspace that currently owns it. On the local path it signalled whatever was running at one instant and continued, which left grandchildren behind an intermediate Task still executing against a session that had just changed hands. It now requests branch closure, which follows current Task relationships, blocks the proven branch from starting more work, and rescans until nothing in scope can continue; if the branch cannot be proven the warp fails rather than claiming a session that is still running. The request is scoped to the session's current owner rather than to whichever workspace the caller routed to. Closure validates that the named session belongs to the location asking by comparing the session's stored workspace against the ambient one, and during a warp the session still belongs to the previous workspace, so an unscoped request is refused whenever the caller routed elsewhere. The previous workspace was resolved from the session's own row, so it names the location that owns it. The remote path is unchanged. Branch closure is process-local, so a remote owner's branch cannot be proven from here either way; that case keeps its existing best-effort history sync. Event replay is ordered against cancellation too. A history batch takes one reservation covering every aggregate it touches, so a fenced member refuses the whole batch instead of leaving a partially applied history whose projection disagrees with the event store. Live sync takes the reservation outside its existing transport catch, so a genuine replay failure still logs and skips as before while a refusal escapes rather than marking the event consumed; the loop re-runs syncHistory on reconnect and picks it up again.
…le aborted tool calls exactly Compaction treated a branch-closure record as an ordinary user message. It counted as a turn, so a turn boundary could start at synthetic evidence at a point the user never sent; it could be selected as a compaction parent; and an overflow replay dropped it along with the span it replayed, losing the only account of a cancellation while the history it describes survived. Closure records are now skipped as turns and as parents, and carried across the replayed span rather than discarded with it. Overflow replay also strips the reserved closure key from each reshaped part. Replay mints fresh coordinates in the same session, so a part that merely carried that key would acquire the bindings the classifier requires and replay would turn ordinary data into a record of a cancellation that never happened. Only the reserved key is removed. Pruning marks completed parts compacted and writes them back, which replaces persisted content, so it is ordered against cancellation. One reservation covers the whole batch, because a refusal landing mid-loop would leave some parts marked and others not. The refusal is logged rather than propagated: pruning is best-effort, its caller discards the outcome, and it runs again on the next prompt. The processor's abort cleanup ends tool calls through the shared terminalizer. It holds cancellation-owned authority already, so this adds no permit; what it adds is a re-read at the exact coordinate, so a tool call that completed between the read and the write is left completed instead of being overwritten as aborted. That guard was previously an invariant held elsewhere rather than checked here. The payload is unchanged: consumers branch on both interrupted and the exact error text. The task fixture now reserves as well as admits. It is named a coordinator that admits, and its reserveMutation stub predated any reservation on this path; session removal reserves its subtree, so the stub would have failed the removal tests on the fixture rather than on the behaviour they assert.
Direct-run read the Task result with its own pattern, matching the first <task_result> anywhere in the output. That format is now one arm of a structured outcome the synchronous return and the async callback already carry, so direct-run reads the same result rather than keeping a parallel format that drifts from it. Two consequences of reading the real envelope. It recognises the arms that carry the body when the child stopped without a normal answer or failed, which previously fell through to the line-stripping fallback and showed the raw envelope. And it anchors to the end of the envelope, scoping past task_prior_output: that section quotes an earlier lifetime's terminal output verbatim and can contain these very tags, so an unanchored search returned the quoted history instead of the current outcome. A failed cancellation is now shown instead of being discarded. Success means the branch is actually closed, so swallowing a failure would leave the user believing work had stopped when it had not. The message says to retry the interrupt, which joins the existing repair rather than starting a second one. Branch-closure records render as the system notices they are. They occupy a user message but were not typed by the user, so replaying one as a prompt put words in their mouth, and admitting one to the prompt-history ring offered it back on up-arrow.
…jectors Replay drives the projectors, which delete rows. Nothing ordered that against a branch cancellation working on the same aggregates, so a replay could rewrite the transcript a cancellation was proving stable. The remaining unreserved replay site is the sync route. It takes a reservation covering every aggregate in the batch before core replay is entered at all: once the durable commit opens its uninterruptible transaction the projector deletes commit with it, so refusing inside that window would be too late. All three production replay sites now reserve. The bridge then requires the permit that a reservation issues, which is what makes routing around it impossible rather than merely discouraged. It is deliberately a permit lookup and never a coordinator call — a coordinator dependency here would propagate to the twenty-odd modules that build this bridge. The refusal decision happens earlier. The sync route terminates its refusal rather than reporting it, unlike the session endpoints. Its consumer is a peer instance rather than a user, it declares a single error, and adding to it changes a peer transport contract and the generated SDK that no test here exercises. That is the one seam in this change where a refusal is not reported. The coordinator is published to the route layer rather than only reached through the run-state node, whose dependency on it is provided inward and not re-exported. A closure record no longer becomes the latest user message. It occupies a user message but is not a turn, so callers resolving a model or a retry against it were resolving against synthetic evidence.
…osed branch Replying to a permission prompt or answering a question resolves the deferred that a blocked tool is waiting on. Nothing stopped that happening after the tool's branch had been cancelled, so a reply arriving just after a stop restarted the work the stop had ended. Both now pass through admission, checked before the deferred resolves. Permission takes one admission for the whole resolved set: its two cascade loops filter on the replying session, so a single reply can never resolve another session's deferred, and per-continuation admissions would let a partial settle strand a resumed tool unaccounted for. Question resolves exactly one deferred, so one admission covers it by construction. Both polarities are admitted, on evidence rather than symmetry. A rejection reaches the processor's tool-call failure path, which writes the part to error - a durable transcript mutation - and only then breaks conditionally, so it is a continuation rather than a proven termination. None of them retries after release. Waiting for closure and then running the body is precisely resuming a blocked tool inside a branch that has just closed, which is what the guard exists to prevent. The joined admission is still settled. Unknown-request replies are unchanged and answer NotFound as before, deliberately ahead of admission. A refusal is reported as a conflict rather than raised as a defect. These are interactive routes, so the refusal is the expected result of the user's own concurrent stop; a 500 would report a server fault that did not occur.
…e records Closing an ACP session removed its record and aborted the backing session from what the removal returned, so the branch was still running while the record it belongs to was being deleted. The abort now runs first, and the session is looked up rather than taken from the removal's return. Close stays total. A failed backing abort is still logged rather than failing the protocol method, so a stuck backing session cannot make an ACP session permanently un-closeable — the behaviour upstream's own test requires. Branch-closure records are recorded so history stays complete, but their parts are not replayed as tool updates or permission requests: they describe work that was stopped, not work for the client to act on. Session restoration skips them too. Such a record carries a model on a user message the user never sent, so restoring from it resumed the session on a model they did not choose. Restoration now takes whole messages rather than their info, because recognising one needs its parts. The warp error mapper names a closure failure specifically. Those errors carry structured fields rather than a written message, so the generic tail reported an empty one for the most likely way a warp now fails.
…r bytes A branch-closure record is the durable account of what a cancellation stopped, and readers trust it: revert refuses it as a boundary, cleanup preserves it, compaction skips it as a turn, and the UI renders it as a system notice. Nothing stopped a caller writing that key itself. A payload carrying it was trusted as a genuine record, so a forged one could make a message permanently un-revertible and un-prunable. The key is now reserved wherever caller-owned part bytes enter. The value is deliberately not inspected: a malformed claim and a well-formed forgery get the same refusal, which keeps this a provenance gate rather than a second, divergent copy of the classifier. Three seams, which is all of them. Part replacement refuses it, ahead of its reservation, because a payload claiming reserved provenance is malformed whatever the branch is doing. Prompt checks the resolved parts once, after the plugin hook has had its opportunity to mutate them and before the message or any part is persisted, so an offending part cannot leave a half-written message. Import decodes and checks the whole file before the first write, so an offending part late in it cannot leave a partial import. The guard is on replacePart and not on updatePart. updatePart is the writer a live execution uses for parts it is producing, called once per streamed token; a check there would run thousands of times a turn against a condition only a caller payload can create, and would widen an error channel across all 38 of its call sites. Splitting replacePart out earlier is what makes this placement available. Each seam reports rather than crashes: 400 on the part and prompt routes, which already declare it, and a CLI failure naming the offending part. Residual, stated plainly: this is fail-closed by placement, not by construction. A future internal writer that takes caller bytes and calls updatePart directly would bypass it. The exposure is narrow because only a synthetic text part, alone in its message and bound to that message's own session, can classify at all.
Adds test/session/task-return.test.ts, exercising the classifier that turns a child session's final state into the structured <task> envelope. Covers: orphaned-interrupted vs unconsumed local tools; evidence ordering between an observed turn and a candidate turn, including a later clean no-text turn preserving an earlier error; the eleven fallback classifications (context overflow, output limit with and without an error, finish error, content filter, unknown/open/missing finish, unconsumed tool, ordinary text, no text); head/tail excerpt bounding with an omitted-middle count; open-field caps and less-than escaping; the complete output-limit golden envelope; degraded-warning ordering; prior output on terminal envelopes only; and the cancelled envelope's known/unknown status. 11 tests, 57 assertions.
…ch suites
Shared harness the branch-closure and task-attachment suites build on.
test/lib/closure.ts unusedJobs / admittingJobs / admittingClosure. Job
capabilities default to dying rather than answering
benignly, so an unstubbed call is a loud failure instead
of the fake deciding admission. A real SessionClosure in
a bare test blocks forever rather than failing, because
InstanceState.get withholds a runtime until its queue and
supervisor are ready; these fakes exist for that reason.
test/lib/attachment.ts Instance fixtures. AttachmentCoordinator.make reads an
ambient InstanceRef, which is a Context.Reference
defaulting to undefined, so a test supplying only a Scope
compiles and then dies at run time. Two named directories
keep isolation tests from passing for the wrong reason.
test/lib/background.ts syntheticAdmission, for tests stating an admission
explicitly. A function rather than a layer: a permissive
layer is selectable from production wiring by accident.
test/lib/physical.ts recordingPhysical, satisfying TaskPromptOps' required
physical member. Records rather than dies because
executeTask's interrupt finalizer crosses this seam on
ordinary teardown.
test/lib/closure-record.ts closure-pair fixtures shared by the run surfaces.
test/lib/effect.ts itBounded / testEffectBounded / FIBER_BOUND_MILLIS. A
bun timeout fails the test without interrupting the fiber,
so a body that never settles wedges the runner instead of
failing; only Effect's own interruption reaches it. Opt-in,
because suites with legitimately slow bodies share the
default runner.
Covers items 3, 4 and 5: a subagent waiting for the async children it started, one start owner per Task session, and a tracking failure not becoming authority to kill healthy child work. test/session/attachment-coordinator.test.ts (28 tests) exercises the coordinator directly: same-ID claims linearize, duplicate scope opens fail until close, observer election, gate release on candidate/observed evidence, never-attached scopes keeping populated slots rather than discarding them for fallback, and ownership arriving after a successful gate closing as a no-op. test/tool/task-attachment.test.ts (18 tests) drives the same behaviour through the Task tool: continuation leases taken before injection and retired after delivery, a refused injection making one attempt without retry, a refused acquisition running no continuation and not degrading, an observer defect degrading once, and adjacent invocations keeping one observer and one parent prompt. The layer is built with LayerNode.compile/group rather than the per-service defaultLayer exports, which no longer exist. SessionClosure is overridden with the admitting fake so the background binder resolves the same coordinator the caller lease was minted from; a split there refuses every job as refused_by_authority and surfaces as a cancelled task.
…ocol Covers item 8: wait, promote, extend and cancel targeting one exact run rather than whichever run currently holds the same public id. closure-job-lifetime.test.ts (13 tests) - the registry's own mechanics: the four-state token, one shared arm attempt per token with a second concurrent start joining rather than binding twice, monotonic invocation sequences, both orderings of the ArmPermit compare-and-set, binder interruption terminalizing exactly one token, and the stale-handle barrier where a replacement under the same public id cannot be observed, promoted, cancelled, waited on or extended through the old handle. It also pins the compatibility boundary: an id-keyed promote still acts on the current occupant, which is why a list-then-promote sequence is not evidence about which run was promoted. closure-job-bind.test.ts (14 tests) - the coordinator half, against the real coordinator and real model with only the driver scripted. A bind whose lease a fence has adopted is cancellation-owned with no escape; a fenced scope refuses at all four attachment lifecycle positions; a fenced result is cancellation- owned and its due wake cannot start a provider turn; targeted child cancellation leaves a sibling parent's wake intact. The last test drives a real caller lease through the registry to the coordinator, asserting the lease owner changes kind from scope to job - a permissive binder would also let the job complete, so the job's status would discriminate nothing. closure-job-binder.test.ts (10 tests) - the bridge translating closure authority into core's arm decision, including location failure and the ABA case where a retired id is reused. An invocation carrying no admission is asserted to be granted, not refused: admission is what a caller opts into, and refusing its absence would reject every caller that predates it. The test also pins the cost - such a job publishes no lifetime and is outside the closure's view. Layers are built through LayerNode rather than the removed defaultLayer exports, with SessionClosure and SessionStatus pinned as overrides so the binder and the admission seam resolve one coordinator instance.
… surfaces
Covers item 16's route half: direct run derives its Task body from the same
structured outcome the other front ends render, and a closure record is
presented as a stopped-branch marker rather than as ordinary conversation.
entry.body.test.ts task entry bodies for completed, error, cancelled and
no-text children, including the streaming and done
transitions for a task part.
runtime.test.ts a typed interrupt failure is rendered and a repair
retry is permitted rather than the run ending silently.
scrollback.surface.test.ts task rows render through the normal scrollback
renderer at each state.
session-replay.test.ts a complete closure pair replays as a system commit,
and a malformed row that resembles one does not gain
system status.
session.shared.test.ts complete closure pairs are dropped before turns are
built, while a generic synthetic lookalike still
contributes a turn and selects a variant.
These five files exist upstream, so only this work's hunks are applied
(+479/-3); upstream's own tests in them are untouched.
TaskTool's startExact call passed the ToolPart metadata object unchanged, so
the background job carried no coordinates identifying the Task part that
started it.
session/closure/discovery.ts reads two job-metadata keys to build a branch
edge:
taskMessage: text(entry.info.metadata, "taskMessageId")
taskCall: text(entry.info.metadata, "taskCallId")
With no producer, both resolved undefined for every job. session/closure/driver.ts
then skipped each edge at its coordinate guard
if (edge.taskMessage === undefined && edge.taskCall === undefined) continue
so the coordinate map was always empty and capture() could not resolve the Task
part it terminalizes. Cancelling a branch recorded an unknown outcome instead of
settling the part, leaving a Task card describing work that had already stopped.
The coordinates are spread into startExact's own metadata argument rather than
into the shared object, so the ToolPart's product-visible bytes are unchanged.
taskCallId is omitted rather than written undefined when absent, so discovery's
shape check reports no coordinate instead of coercing one -- missing evidence
must not widen cancellation authority.
Reading absent optional metadata is type-safe and yields undefined, so the
compiler could not see this; the two closure suites that cover the driver set
taskMessage/taskCall directly on the discovery item, bypassing the metadata
read, so they could not see it either.
packages/opencode typecheck exit 0; test/tool 433 pass / 0 fail; test/session
462 pass / 3 fail (the recorded session.llm.stream provider-payload failures,
unchanged).
Runs the producer and the consumer rather than the fields between them.
tool/task.ts writes taskMessageId and taskCallId into the background job's
metadata at its one startExact call. session/closure/discovery.ts reads exactly
those keys off entry.info.metadata to build a branch edge, and
session/closure/driver.ts skips any edge carrying neither before it records a
coordinate -- so a Task part with no coordinates is never resolved, and
cancelling its branch records an unknown outcome instead of settling the part.
Two tests drive a real async Task through the tool with a real BackgroundJob,
hold the child open so its job is live, then read the shipped
SessionClosureDiscovery capability and assert the coordinates arrive:
- with a call id, both taskMessage and taskCall are populated
- without one, taskMessage is populated and taskCall is absent, because the
producer omits the key rather than writing undefined
Both also assert the driver's guard condition directly, since the coordinate
assertions alone would still hold if that guard changed shape.
Verified by removal, not by assumption. Deleting the two producer lines from
task.ts turns both tests red with taskMessage undefined, and leaves the other
18 tests in this file green -- which is what establishes that the existing
suites cannot detect this. The closure suites that cover the driver set
taskMessage/taskCall directly onto a discovery item, so they supply the value
production is supposed to produce. Restoring the lines returns the file to
20 pass / 0 fail; task.ts is byte-identical to HEAD after the control.
Discovery needs a SessionPhysical to build. It is never invoked here: discovery
stores each entry's interrupt as an unevaluated Effect, so reading jobs observes
metadata without signalling anything.
packages/opencode typecheck exit 0; test/tool 435 pass / 0 fail (was 433/0).
Two comments in closure-job-bind.test.ts described what the assertions below them check by pointing elsewhere, which left a reader following a reference instead of reading the constraint. They now state it: a real fence taken through the real claim path diverges from a scripted refusal, and only the real path reflects what production does; and a fence never rewrites a consumed lease to revoked. No behaviour change, comments only.
Contributor
|
The following comment was made by an LLM, it may be inaccurate: Based on the search results, I found one related PR (not a duplicate):
No actual duplicate PRs were found. The current PR (#45482) is addressing a comprehensive redesign of the Task/background job system with no overlapping open PR tackling the same work. |
This was referenced Aug 27, 2026
added 17 commits
August 31, 2026 04:49
…032 R-08) A scope that has published its resolution stays registered until `closeNow` unregisters it, and `locate` returned it to `executeSupplement` as borrowable. Borrowing one loses the supplement's answer silently: `own()` returned on the `state.closed` guard before reaching the throw, so no typed refusal was minted, and `result()` short-circuits on `state.resolution` and replays the earlier resolution and fallback. The run then files the earlier controlling position, which `ledger.filed` already holds, and its own distinct answer disappears with no note and no error. Once CP-032 B-1 parks an owner run inside `Scope.result()` that window covers every concurrent supplemental sequence rather than a few fiber hops. Adds `AttachmentCoordinator.locateBorrowable`, which answers the borrow question only. Raw `locate` is deliberately unchanged: of its three production consumers, `tool/task.ts:289` reconciles the carried parent scope by object identity and fails the call on disagreement, and `session/attachment/participant.ts:124` reports covered edges for closure proof and must not narrow the proven set. A global resolved-filter inside `locate` would regress both. `open` now replaces a RESOLVED incumbent in place instead of failing, via a private `resolved()` thunk on the registry entry rather than a new field on `AttachmentContract.Current`. The check-and-swap is one `Effect.sync` critical section and `closeNow` runs inside `transition`, so it is atomic without a new lock. A live incumbent still loses the exclusive open, so a degraded-but- unresolved scope keeps throwing from `own()` and keeps producing CP-031's recoverable admission failure. `Scope.own` now returns whether ownership was taken. A borrow check cannot rule out resolution by itself: `promptAdmitted` yields through `revert.cleanup` and `createUserMessage` between discovery and ownership, so a scope live at lookup can resolve before the claim. Returning false lets that boundary raise `SessionScopeOwnRefused` before `onAdmitted` fires, routing the supplement to the sanctioned pre-admission note instead of a stale replay. The no-op itself is preserved and load-bearing -- taking ownership would `invalidate()` a settled candidate and let `result()` fall through to a different fallback -- so the non-admission callers at prompt.ts:525 and prompt.ts:1235 ignore the result and "ownership arriving after the successful gate closes is a no-op" passes unchanged. Fence bindings are deliberately untouched. A ref authorizes the exact scope generation it was captured on; replacement rewrites only `registry.scopes`, so an old ref keeps cancelling its own generation and never reaches the successor. Files: packages/opencode/src/session/attachment/coordinator.ts, packages/opencode/src/session/prompt.ts, packages/opencode/src/tool/task.ts
d4dbd0e keyed the new CP-032 R-08 ownership refusal on `state.closed`. Every resolution sets `closed`, but the two are not the same state and conflating them is a regression. Only a RESOLVED scope holds an answer a later `Scope.result()` would replay, so admitting onto it loses the distinct answer silently. A scope merely TORN DOWN has no answer to replay: `finalizeOwnerScope(Exit.void)` closes and degrades it, and `gate()` cannot resolve without evidence, so it sits closed and unresolved. Refusing there failed a run that used to proceed through the ordinary degraded route. `own` now returns false only when `state.resolution` is set, and passes merely-closed scopes through as the historical no-op. Surfaced by closure-task-boundaries.test.ts "refuses the real Task result notifier before scheduling its observer (K9 result)", which d4dbd0e was committed without running. Base 8094d33 passes that suite at 6/0; d4dbd0e fails it at 5/1. Adds a production-shaped oracle for the accepted failure shape. It wraps `TaskPromptOps.prompt` to resolve the scope AFTER `executeSupplement` has made its borrow decision, then delegates to production `promptAdmitted`, so the refusal, its classification and its note are the shipped ones. Proves one sanitized note carrying the shipped transcript disclosure, no provider call, no filed answer, and no terminalization (CP-031 R-24). A two-phase pre-persistence ownership hold was designed, implemented and rejected on merit. CP-032 B-7 retains typed pre-admission refusals as sanitized notes, `supplementalAdmissionNote` discloses that the prompt may already be recorded in the transcript, and `SessionPrompt.ownLatestUser` adopts an unowned latest User message on a later scoped run, so the refusal loses nothing. The hold would have added an indefinite gate-stall surface to core quiescence, whose failure mode is a hung Task rather than a truthful note. Ref: CP-ocp-032 R-08, B-7; CP-031 R-24 and the recoverable admission failure retained for degraded/cancelled-unresolved scopes.
extendExact collapsed every nonaccepted admission arm to undefined and settled Exit.succeed(undefined). That erased the cause before settle could read it, so a lifetime whose admission authority already held cancelled was published as completed, and a binder defect was laundered into a clean finish. The inner admission block now returns a tagged Acceptance: admitted carries the minted handle, declined covers ordinary rejection plus the stale/foreign registry guards, and cancelled covers binder cancellation_owned and a permit lost to revocation. onExit maps admitted to a no-op, forwards a real Exit failure cause unchanged, expresses a decision-derived cancellation as Cause.interrupt(), and leaves ordinary refusal on the existing no-output success strand. settle is unchanged. It already mapped success to completed, hasInterruptsOnly to cancelled, and everything else to error with the original cause preserved, so the repair carries the cause to it rather than remapping there. No per-sequence physical cancellation and no second terminal winner are added. Files: packages/core/src/background-job.ts (extendExact), packages/core/test/background-job.test.ts Tests: five arms cover cancellation_owned, revoked permit, interrupt while binding, binder defect, and the ordinary-refusal control that must stay completed. Red-first 45 pass / 4 fail, now 49 pass / 0 fail / 603 expect. Ref: CP-ocp-032 B-2, R-10
The R-08 refusal was covered by a note assertion, which is satisfied by a refusal anywhere upstream of Task classification. The production oracle in closure-task-boundaries now pins the exact boundary and its cost. The raced TaskPromptOps.prompt wrapper also wraps input.onAdmitted rather than replacing it, so production promptAdmitted still runs underneath. Added assertions: onAdmitted never fires; the User message and its text Parts are durably persisted in the child transcript; no Assistant turn is produced and llm.calls is unchanged; and the resolved scope earlier answer text reaches no delivered surface. Mutant control: forcing the own() result to be ignored in promptAdmitted drops notes from 1 to 0 and fails the oracle, so the assertions discriminate the defect rather than restating current behavior. Source restored and re-verified. Comment corrections. Refusing here was described as pre-admission, which reads as before anything happened. The exact boundary is after durable persistence of the User message and its Parts but before Task onAdmitted, and that is what keeps the outcome a sanitized B-7 note instead of a post-admission failure. Corrected in prompt.ts, attachment/coordinator.ts own(), the task.ts executeSupplement classifier note, and the coordinator test commentary. The existing accurate sentence describing persistence preceding the scope join is preserved verbatim. Files: packages/opencode/src/session/prompt.ts, packages/opencode/src/session/attachment/coordinator.ts, packages/opencode/src/tool/task.ts, packages/opencode/test/session/closure-task-boundaries.test.ts, packages/opencode/test/session/attachment-coordinator.test.ts Tests: closure-task-boundaries 7 pass / 0 fail / 104 expect. Affected radius 152 pass / 0 fail / 934 expect across 10 files. Typecheck clean in core and opencode. Ref: CP-ocp-032 R-08, B-7
CP-032 B-1 puts a genuine await into the detect-to-file span: owner and supplemental runs will await Scope.result before filing. That reopens the ordering window OBL-1 recorded as closed, so the mechanism removed in aeb6699 comes back. Reconstructed by behavior, not reverted, because the key changes. Keyed on SEQUENCE rather than the original (at, position) chronology key. Return eligibility can select a controlling assistant OLDER than the run-final one it was detected from, because a degraded resolution falls through to the retained fallback. The announced key and the filed key are then different messages, so a chronology floor compares the wrong thing. A sequence is fixed at admission and cannot drift. Surfaces: Announce is nullary and closes over both ledger and sequence, so a run can never mark another ledger or name another sequence. LifetimeLedger.announced is Set<number>. AnswerLog.Entry and the Publish action carry sequence, Observe carries floor, and transition withholds when entry.sequence > action.floor. waitAnswer computes the floor as the lowest announced sequence. Buffered carries sequence too, so a promoted drain republishes under the real filing sequence instead of an invented one. settle and fork regain the sequence parameter that ce8b3d3 removed. Clear authority by site: exact-sequence clears in the settle not_running arm, the settle main arm, and the fork finalizer that nets interruption and replaced lifetimes; ledger-wide clears at settle terminalization and in cancelOn, where nothing can file afterwards. Inert until B-1 supplies the announce call in task.ts. Nothing announces yet, so floor is always undefined and delivery is unchanged; the whole pre-existing suite passes untouched. T-41 gains the Passage AK presence census and keeps its single position-membership invariant. That count staying at 1 independently validates the key choice: the chronology floor needed a second ledger.filed.has read inside waitAnswer, while this one needs none, because settle clears an announcement in the same modification that files. Files: packages/core/src/background-job.ts, packages/core/test/background-job.test.ts, packages/opencode/src/background/job.ts Ref: CP-ocp-032 R-06, R-10, Passage AK
The CP-032 R-08 end-to-end oracle in closure-task-boundaries.test.ts was declared with it.instance, which does not layer experimentalBackgroundSubagents. That oracle drives executeSupplement through task_id, a path that exists only when the flag is on, so it passed only while the shell exported OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS. Scrubbed-environment probe before the fix: 6 pass / 1 fail / 94 expect, failing exactly on this oracle. After moving it to background.instance, which sets the flag through backgroundReplacements: 7 pass / 0 fail / 104 expect with OPENCODE_EXPERIMENTAL and OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS both unset. The 10 recovered expect calls are the assertion body that never ran under the bypass. Production seam and mutant assertions are unchanged; only the layer declaration and an explanatory comment differ. Ref: CP-ocp-032 R-08, B-7
…nal truth
B-1 and B-3 land as one slice under CP-032 R-11: enabling the async Scope.result consumer without status-faithful finalization would make a cancelled child return an earlier turn retained fallback as degraded evidence.
B-3, in attachObservation: the lifetime waiter read waitHandle and then discarded it, calling finalizeOwnerScope(Exit.void). It now maps the authoritative BackgroundJob terminal onto the owner scope Exit -- cancelled to Exit.failCause(Cause.interrupt()), error to Exit.fail, otherwise Exit.void. The Exit is the carrier because AttachmentCoordinator.finalizeScope already maps interrupt to claimCancellation, failure to degrade, and success to close, so no new coordinator entry point is added and the projection cannot drift from the mapping every other finalization site obeys. The other nine finalizeOwnerScope callsites are deliberately unchanged: Exit.void is not neutral, it claims nothing and degrades nothing, so closeNow marks an unresolved scope degraded -- the truthful no-authoritative-terminal disposition.
B-1, in detect and executeSupplement: a new eligible() gate is the single path both run shapes take. With no scope, or a scope that already resolved, it returns immediately eligible evidence. Otherwise it announces the sequence, awaits invocation.result(), and derives filing identity from the controlling selected Assistant. A cancelled resolution returns undefined so nothing files. detect calls eligible AFTER its owner Assistant-error and failed-ToolPart checks, so those exits announce nothing; executeSupplement reaches it without them. That asymmetry is preserved, not copied.
A-1: renderAnswer now renders the retained selected record directly instead of rebuilding {fallback, degraded:false}, which had made candidate, observed and degraded selection unreachable on every observer route. controllingAssistant is exported from task-return.ts and reuses the private select rather than restating its precedence. The synchronous return consumes Info.output and the obsolete second ownerScopeHolder.scope.result call is deleted -- it would have latched a non-message as the retained fallback.
AttachmentContract.Current gains a readonly resolved flag. The eligibility gate is one-shot, but R-23 keeps an opened scope live through descendants, so a second sequential run on the same session inherited a resolution computed for a different turn, filed at the earlier position, and had its answer swallowed by the filing guard. Four parentPrompts assertions in task-attachment.test.ts caught it. eligible() now pre-checks current().resolved and takes immediate eligibility on its own turn.
T-032-3 adds the K14 oracle in attachment-coordinator.test.ts: a scope holding an earlier distinctive answer as its retained fallback returns cancellation with that text absent, while the control arm finalizing the same fixture as Exit.void reproduces the defect by replaying it as degraded evidence. T-032-1 in task-attachment-scope-lifetime.test.ts is re-framed from asserting the incident as correct to proving a yield files nothing until eligible.
Ref: CP-ocp-032 R-01, R-02, R-03, R-04, R-11, R-12, C2, C3
…contract AttachmentContract.Current gained a readonly resolved field in 109e77b. That was the wrong surface. AttachmentContract.Scope is the narrow read-only observer contract -- id plus a current() snapshot -- and Current is its payload, consumed by closure-driver.test.ts through Ref.make of AttachmentContract.Current. Return eligibility is a Task-boundary concern and does not belong in a contract whose stated subject is local attachment-lifetime state. AttachmentCoordinator.Scope extends AttachmentContract.Scope, so a member added there reaches the Task boundary that holds the coordinator handle without widening the shared contract. resolved is now a thunk on that interface, mirroring the registry-entry resolved() thunk added for R-08 in d4dbd0e, whose comment already recorded this exact rule: kept private so no public contract widens and no existing current() consumer changes. Evidence: git diff 8094d33 for packages/opencode/src/session/attachment/contract.ts is now empty, so the observer contract is byte-identical to base. Consumers updated: eligible() in task.ts reads invocation.resolved(), and the T-032-1 liveness assertion reads scope.resolved(). Verified with OPENCODE_EXPERIMENTAL and OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS unset: 197 pass / 0 fail / 1632 expect across 12 files, including closure-driver and closure-job-bind, the two genuine AttachmentContract consumers. Both typechecks exit 0. Ref: CP-ocp-032 R-01, R-07, R-23
The parent carried-scope reconciliation in task.ts degraded whatever the registry held: if AttachmentCoordinator.isScope(located) then located.degrade(). Before CP-032 R-08 a session held at most one generation, so the registry occupant and the faulting generation were the same object. Atomic replacement makes two generations coexist for one session: a resolved predecessor still referenced through ctx.extra.attachment by an in-flight delegated call, and the live successor that replaced it. A stale call carrying the predecessor samples the successor at attachments.locate(ctx.sessionID), fails the carried === located check, and degraded the successor -- an innocent generation correctly serving a different run, whose own answer would then take the failed route. The rule is now generation-correct: degrade the carried scope whenever it is a valid Scope, otherwise the located one, and fail either way. With no carried scope there is no faulting generation to name, so the registry occupant remains the only thing to degrade, which is the original behaviour. Fail-closed is unchanged in both arms. Two discriminating rows in task-attachment.test.ts. Stale-carried: the call fails, childPrompts stays 0 so nothing reached the provider, the successor is neither degraded nor cancelled, remains the registry occupant, and still selects its own evidence. Missing-carried: the call fails and the registered scope is still degraded. Mutant evidence -- restoring the located-only degrade kills the stale-carried row and leaves the missing-carried row passing, 22 pass 1 fail, which is the discrimination the pair is for. Also corrects the Scope.own interface docblock. After 1b03a15 a true return does not mean ownership was taken: a scope that closed without ever resolving also returns true as the historical no-op. Only an already-published resolution returns false. Verified with OPENCODE_EXPERIMENTAL and OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS unset: 199 pass / 0 fail / 1643 expect across 12 files. Both typechecks exit 0. oxlint 17 warnings 0 errors on the three changed files. Ref: CP-ocp-032 R-08, R-23
CP-032 v0.12. A pre-published resolution speaks for the turns it covers, not for every later caller. The coordinator now records which Assistants a resolution covers and consults that at result() time, inside the same synchronous transition that returns the selected structural result. Mechanism, all private: State.members is a mutable monotonic Set of MessageID, and State.publishedMembers is a frozen non-aliasing snapshot taken at each of the three evidence publication points (the degraded and clean branches of gate(), and the never-attached immediate mint in result()). Resolution and TaskSelectedReturn are byte-unchanged, so no public schema, API, Current field, timer, poll, or lock is added. Enrolment happens at accepted observeTurn before gate() runs, and at every result(runFinal) entering unresolved before the fallback latch, so waiters enrol too. invalidate() clears candidate and observed only, never history. closeNow clears the mutable set only when a resolution exists, because apply() runs gate() after closeNow and an unconditional clear would let a close-triggered publication freeze an empty membership. Decision in result(): a pre-published cancellation consumes globally and yields no controlling Assistant, so nothing files. Pre-published evidence, clean or degraded alike, consumes the selected structural result when the incoming Assistant is in the frozen membership, and otherwise returns fresh non-degraded evidence without mutating fallback, history, or resolution. Callers that entered unresolved are not reclassified: they park on the one-shot Deferred and consume the one result. The no-retained exception is removed. Task: eligible() no longer samples scope state. Scope-less turns are immediately fresh; every scoped path announces its sequence and makes exactly one Scope.result(runFinal) call. The Task-facing Scope.resolved accessor is removed from the interface, implementation, and handle; the registry-private resolved() thunk that R-08 borrow refusal and atomic replacement depend on is unchanged. Why membership rather than the frozen candidate/observed/fallback trio: those slots are replaceable. An Assistant observed and then displaced by a later admission is still covered by the resolution while occupying no slot, and testing slots would falsely revive it as fresh. Why not degraded-only, as v0.11 had it: a clean resolution minted while a distinct admitted run is still producing must not swallow that run answer. Eight mutants, each killed by a discriminating arm. Trio proxy and clear-on-invalidate each kill only the displaced-observed arm. Omit-observe-registration kills four. Unconditional evidence kills four. Always-fresh kills five. Degraded-only kills the R-08 successor and no-retained arms. No-retained-exception kills the no-retained arm. Self-enrolment required contaminating the frozen snapshot through a ReadonlySet cast to be observable at all, because the post-publication decision reads only the frozen set. Sixteen pre-existing rows were migrated rather than having expectations edited. Four were a lazy-fork ordering defect where a forked probe was only scheduled, never parked, and needed a yieldNow. Twelve read the settled selection by calling result() after publication with a foreign Assistant, which is now the fresh case by design; they read through a genuine pre-publication entrant instead. Three assertions that were themselves reader-idempotence are retired in place with their rationale, and the foreign-ID behaviour moved to the nonmember-fresh arms. Verified with OPENCODE_EXPERIMENTAL and OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS unset: 12-file affected radius 210 pass / 0 fail / 1701 expect; coordinator 48 / 0 / 199; core background-job 52 / 0 / 619 unchanged. Both package typechecks exit 0. git diff --check clean. oxlint 23 warnings 0 errors on the five changed files. Ref: CP-ocp-032 v0.12 R-01, R-02, R-07, R-08, R-23, B-7
Route all evidence and cancellation publication through publishResolution. Evidence copies the covered Assistant IDs into an immutable snapshot before releasing the mutable set, so close-triggered degradation cannot freeze empty history and post-publication arrivals cannot alter authority.
Add E1-E6 structural oracles plus production Runner P1/P2 races in attachment-coordinator and closure-task-boundaries tests. The real child path now proves covered displacement and the complementary clean-mint freshness race through promptAdmitted, observeTurn, and result.
Ref: CP-ocp-032 v0.13 sections 3.3.2, 9.2, and 13.2
Files: packages/opencode/src/session/attachment/coordinator.ts
packages/opencode/test/session/attachment-coordinator.test.ts
packages/opencode/test/session/closure-task-boundaries.test.ts
Project the authoritative cancelled terminal into the owner attachment scope before returning the success-shaped synchronous Task result. This prevents release-time success from degrading the scope and replaying a retained earlier fallback. Cover the production K14 cancelled/error path, the synchronous red-first discriminator, and already-elected descendant suppression for answer and cancellation terminals. Refs CP-ocp-032 T-032-3.
Remove root-route cancellation suppression and render unknown status on BackgroundJob.Info-only sync and observer terminal paths. Add T-032-10 transport, refusal, status, and carrier-boundary coverage.
Exercise BackgroundJob.Announce and waitAnswer across exact, no-file, interrupted, terminal, cancellation, promotion, and ABA clears. Add Task integration coverage for controlling-answer filing identity, owner/supplement announcement asymmetry, and foreground ordering required by CP-032 T-032-6.
Author
|
Notice - I'm tracking down some bugs in this PR. |
added 4 commits
September 4, 2026 11:49
Preserve legacy ensureRunning joins while adding a distinct FIFO publication path for reply-required work. Each accepted entry owns start, release, and result barriers; detached generations retain exact lifecycle, cancellation, shell, context, and Instance-disposal ownership.\n\nDeterministic tests cover late publication, all-cause FIFO promotion, transition-owned result arbitration, logical-idle ordering, registration closure, map-orphan disposal, and queued physical cancellation.\n\nRef: CP-ocp-033 v0.9\nFiles: packages/opencode/src/effect/runner.ts, packages/opencode/src/session/run-state.ts (cherry picked from commit 921b56944f6e29b0d4c87c74522a35a2a82f7054)
Publish prompt, Task callback, command, public-loop, and manual-summarize work under their existing admission boundaries, release execution only after publication admission exits, and await each FIFO result outside wrapper-owned leases.\n\nKeep noReply outside the Runner, preserve internal Task and loop polarity, and leave message-free wakes on legacy ensureRunning joins.\n\nRef: CP-ocp-033 v0.9\nFiles: packages/opencode/src/session/prompt.ts, packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts (cherry picked from commit a783af288ba36b1e8ee3eec2fb7d7aa58a28bc4b)
Add a deterministic scheduler handoff around an Idle selected FIFO entry so deleting the release wait exposes early body execution. This closes the sole surviving CP-033 mutation case without adding a production seam or timing oracle.\n\nRef: CP-ocp-033 v0.9\nTest: packages/opencode/test/effect/runner-fifo.test.ts (cherry picked from commit 4e22b140326f14ba38edb3634bd5a5888d9e49ed)
Add a target-local SessionRunState mock that proves reply-required loop ingress uses FIFO publish and outside-await rather than legacy ensureRunning. Refresh Task attachment and closure-registry comments to name the selected execution path while retaining ensureRunning for wake. Ref: CP-ocp-033 v0.10 Files: packages/opencode/test/session/prompt.test.ts, packages/opencode/src/tool/task.ts, packages/opencode/test/tool/task-attachment-scope-lifetime.test.ts, packages/opencode/test/session/closure-update-registry.ts
Author
|
bugs found, fixed, pushed. |
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.
Issue for this PR
Closes #45480.
Depends on #43510, and reads best merged after it. When a called agent has async children outstanding, the runtime tells it once they have all finished — and that confirmation is a trailing, request-only user message, which is exactly the shape that takes a prompt-cache breakpoint under positional selection. #43510 places appended messages after cache selection so they cannot consume one. Without it the confirmation can, though only when more than one request-only message is present, so the cost is conditional rather than certain.
#43510's seven commits are included in this branch because GitHub cannot express a dependency between pull requests. They are not this work and should be reviewed there; once #43510 merges they drop out of this diff on rebase.
Type of change
What does this PR do?
Delegating work to a subagent is unreliable today in five specific ways. A caller can be told a child succeeded when it actually failed. A called agent can answer its own caller before the work it started comes back. A task that produced two answers reports only one, because the runtime cannot tell which one belongs to the call that is waiting. A caller cannot safely add anything to a task already running — passing its
task_idextends the run, but two calls race and one answer goes undelivered. And a stop request can leave deeper work running while reporting success.This makes delegated execution answer honestly, deliver every answer exactly once and in order, and stop completely when asked.
One defect runs underneath the model-facing instruction and schema: the vocabulary names the wrong axis.
backgroundversusforegroundnames a placement — where the task runs. Nothing ever moved anywhere. The real distinction is whether the caller waits. That wrong axis is also what produced instructions the runtime could not honour: the Task tool text told the model "You will be notified automatically when it finishes," in four places across three constants and a parameter description. There is no notification separate from the result. The delivered result is the notification. Four copies of one false promise, drifting independently against each other.The storage, identity, ordering and cancellation defects are independent of the naming; they are not consequences of it.
So the parameter is now
async, advertised under that name across the tool schema, the TUI keybind and command, the task card, the shared session UI and CLI help — and the instruction text is one named protocol.Display copy moved; contracts did not. The environment variable, the
session.backgroundcommand name, thebackground: truemetadata field andBackgroundJobitself are deliberately unchanged, which is whybackgroundstill appears throughout the diff. The one API text that did change is thesession.backgrounddescription, which told users their subagents "continue in the background" — the same false axis, in a document people read.Async Task Protocol — defined once, referenced everywhere. Upstream's three overlapping instruction constants are replaced by a single protocol block (
packages/opencode/src/tool/task-protocol.ts) appended to the tool description. The runtime's short messages now function-call to it by name instead of restating instructions in full with every Task call:The task is running asynchronously. Follow the Async Task Protocol.A test pins the constant-to-protocol relationship so the short messages and the protocol cannot silently diverge.It also promises only what the runtime delivers. "You will be notified automatically when it finishes" became "Because async Task results and errors are delivered automatically, sleeping, polling for progress, or requesting status is unnecessary." The old text described a mechanism that does not exist; the new text describes an outcome that actually occurs. Centralising it removes one specific failure — three copies of overlapping rules drifting apart from each other — rather than guaranteeing the text agrees with the runtime.
A caller that is blocked synchronously is not handed the async protocol, because it does not apply to it. Which receipt a caller gets is resolved from the run that was actually accepted, rather than from a reusable id that a replacement could since have taken over.
How a result reaches a caller. Every outcome is wrapped in one envelope:
The inner tag follows the state:
task_resultfor a completed answer,task_errorfor a failure, andtask_statusfor a receipt on work still running. Upstream wraps a still-running receipt intask_result— the same tag as a real answer — so a model reads<task_result>The task is working in the background…</task_result>and is shown a status message dressed as a result. That is this feature's central confusion, encoded in the markup.state="cancelled"is also new: upstream turned a cancelled child into a tool failure, which cannot be told apart from "the task tool could not run."A caller receives that same structured result whether it waited or not — as the tool's own output when it waited, and as a delivered callback message when it did not. These are not the same channel: the synchronous one resolves the tool call the model is blocked on, while the asynchronous one is a new prompt into the caller's session carrying a synthetic user-role text part, which starts a turn or joins one in flight. Same payload and classifier; categorically different carrier, and one message per answer rather than one per call.
If the caller has gone idle and has no turn left to observe a result, the observer schedules one forced provider turn so the result is actually read. That wake enters the existing turn rather than writing a new user message, so the model is asked to generate again over a transcript it has already answered — a real provider round trip. Exactly one such turn is taken per delivery, so a result arriving while the caller is idle cannot set off a chain of them.
A caller can send a prompt to work already in progress. Passing
task_idfor a task that is still running joins that task's conversation as a supplemental prompt, instead of racing the run or starting a second one. It is queued for admission rather than interrupting the turn in flight; the subagent takes it into account at its next history reload, and if the turn has already ended, the prompt starts a new one. Two prompts arriving together are ordered rather than interleaved, and a prompt that cannot be admitted says so in a notice instead of failing silently.Every answer that gets produced can be retrieved. Sending a second prompt to a running task is what makes this matter: upstream chains it behind the first as a second run, so two prompts produce two runs and two answers — but a job has a single output slot, and the second answer takes it. The first is not destroyed; it is still in the task's session and readable by
task_id. What is missing is any way for the runtime to hand it to the caller whose prompt produced it, so that caller gets nothing while a different caller's answer occupies the slot. Answers are now filed individually against the position each was produced at, and released in the order the conversation produced them rather than the order the runs happened to finish.A called agent cannot answer before its own children do. The system, not the model, decides which turn-end is the answer. A subagent that started async children has its caller's Task call held until those children have been observed and delivered — and a turn that merely paused at a tool call no longer counts as a finished answer. The gate is inert for ordinary delegation: a subagent that started no async tasks is never gated at all.
The agent is told the same rule, because a gate it cannot see would only look like a hang. While async tasks it started remain outstanding, ending its turn does not return its result; the first turn-end after they all finish is the one that returns to its caller. The protocol also states that this response must be a complete, self-contained answer drawing on the task it was given, the results it collected, and its own reasoning. Without that, we have observed agents answer the task prematurely, with a later assistant message becoming the return — such as a remark about the last async task to come back — leaving the answer the caller actually wanted behind in a session that has to be resumed to recover it.
Task return failures are told apart. This is about how a finished child's outcome is reported, not about run-level tool failures, which are unchanged. Context overflow, an output limit, cancellation, and a child that ended with no error object all arrive distinguishable. Each names what the runtime established, marks what it could not as unknown rather than guessing, and points at the task session so it can be inspected or resumed. When a task return failure occurs, the child session's last message is relayed as evidence: long text keeps its beginning and end, with the middle truncated and the amount dropped stated. Short
task_noticelines carry facts that ride an answer without becoming part of it.Stopping, stated once. Double-Escape stops the session you are viewing: its own running work, plus every still-runnable task descendant the runtime can currently prove sits beneath it in the same instance and project directory — and nothing above or beside it. A parent's or a sibling's work is structurally excluded, not filtered: the walk terminates at the requested session, so it never descends into them.
That set — one session plus its provable running task descendants — is what this PR and the transcript records call a branch. It has nothing to do with Git.
That rule now holds where it previously did not. Grandchildren behind an intermediate task that already finished are no longer missed. Cancellation acts on the exact run it observed, so it can no longer land on a replacement that inherited a reusable id and then spread outward from there. Where membership cannot be proven from live job, execution and task-call evidence, the operation fails rather than guessing. Abort returns success only once work has stopped and the branch is released, so a client can treat success as a real boundary before undoing or deleting.
A failed stop is now visible instead of silent, with a closed set of five reasons surfaced as an
Interrupt failedtoast in the TUI and an error line inopencode run. Success stays silent. Undo and redo run only after a successful abort of the session's branch, and showUndo failedorRedo failedwhen that abort does not succeed, rather than proceeding against work that did not stop.Cancellation records carry
opencode.branch_closurein part metadata, and that key is reserved rather than validated: caller-supplied bytes claiming it are refused wherever they can arrive — prompt ingress,Session.replacePart, thepart.updateroute as a 400, and CLI import, which fails before its first database write. Reserving the key means a forgery and a malformed claim get the same refusal. Double-Escape also needed one further fix to reach any of this: the interrupt command was enabled from the selected session's own status, so it was disabled in exactly the case it exists for — a parent that reads idle while its subagents are still working.Cancellation leaves an honest record. Stopping no longer deletes sessions, rewrites a finished result as cancelled, or leaves a task card spinning on dead work. A cancellation writes durable
[Branch closure]records into the transcript, rendered in every UI and readable by the model like any other text, so both the agent and the user can see that a stop happened and what it reached. Undo, replay, cleanup and deletion reserve their place against an in-flight cancellation instead of racing it.Most of this is not behind the experimental flag.
OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTSappears in six production files. It gates theasyncparameter and its protocol, some task lease branches, thesession.backgroundendpoint, the TUI's async affordances, and the attachment participant inside closure. Everything else lands on the default path — the stop rule and its records, the message hygiene around them, the reserved-metadata gate, exact-identity cancellation, and the interrupt race fix below. A user who never enables the flag still gets all of that, which is also where the immediate value is.Cancellation records are user-role messages, so everything that asks "what did the user last say?" had to learn about them. Eleven surfaces: model and retry resolution, compaction turn counting and parent selection, overflow replay — carried across rather than dropped, and stripped of the reserved key so a replay cannot mint a record — undo and redo target rejection, the undo fallback, revert cleanup, TUI navigation, and the
runprompt history ring. Without this, injecting a record would move compaction boundaries and let undo land on a message no user wrote.One hardening fix in passing. Aborting now ends each tool call through the shared terminalizer, which re-reads at the exact coordinate before writing, so a call that completed between the read and the write is left completed instead of being overwritten as
Tool execution aborted(session/processor.ts:578-606). Upstream gets that protection from an invariant maintained elsewhere — settled calls being removed from the in-flight map — rather than from a check at the write. The payload is deliberately unchanged: consumers still branch on bothinterrupted === trueand the exact error text.190 files across six packages, 67 commits — seven of them #43510's, carried for the reason given above. The first commit is separable and can land on its own; see Reviewing this.
How did you verify your code works?
This suite is not green on pristine
dev, so a bare count from the branch would mislead. Both sides were measured at3a31c4ea80across nine runs — eightpackages/opencodeshards pluspackages/core.3a31c4ea80Failure sets were compared as sets rather than counts. The branch's twelve are a strict subset of pristine's thirteen: every failure here also fails on unmodified
dev, and the branch introduces none of its own. The thirteenth is load-dependent rather than fixed —SIGINT interrupts an active non-interactive runfailed at 30001.81ms and 30001.83ms in two earlier runs, which is the harness's 30-second ceiling exactly, and passed in this one. The rest are pre-existing and environmental: symlink handling, a path resolver, an HTTP search endpoint, three provider-payload assertions, and a cross-spawn case in core. The pass counts are not comparable — the branch carries several hundred tests upstream does not have — which is why the failure set, not the pass total, is the claim being made here.Typechecks exit 0 in
core,opencode,tui,session-uiand the JS SDK, and were confirmed to discriminate by injecting a type error, observing it reported, and restoring the file byte-identical. The build was launched under a real PTY at this exact tip and painted a first frame carrying all four markers — a gateserveand--versionare both required to fail, since neither paints.Two gaps. All of this ran on Windows only, which for a change about abort, cancellation and child-process lifetime is a real limitation — two of pristine's thirteen failures are themselves a SIGINT subprocess case and a cross-spawn case, so the platform is not incidental here. And the commit that enables the interrupt command has no test of its own: no existing suite imports that file, so the shard run shows it breaks nothing without showing that it works.
How those figures were produced, and one implementation caveat
Both runs used
--timeout 30000. Several suites legitimately run past Bun's five-second default, and a Bun timeout fails a test without interrupting its fiber, which manufactures failures that look like broken wiring.On
appandenterprisea bare typecheck proves nothing on Windows: a symlinked declaration file materialises as text and the compiler aborts before reading any project file. Those ran with symlinks materialised, validated by injecting a type error and confirming it was reported. The TUI needed launching separately because typecheck and the test suite both bypass its transform and never construct its renderer, and neither--versionnorservepaints.The
[Branch closure]provenance key is reserved rather than validated, so a forged claim and a malformed one are refused identically. The refusal sits on every writer that can carry caller-supplied bytes, including the generic part writer, and at each point those bytes enter: prompt ingress, thepart.updateroute as a 400, and CLI import, which fails before its first database write. One internal path bypasses it, to replicate bytes that were already persisted and already checked; it is lexically private and has no caller-reachable route.Compatibility and limits
backgroundstill works. It is no longer advertised in the tool schema, but it still decodes, still selects asynchronous execution, and logs a deprecation warning; the pre-existing task suite passes unmodified calling with it.One instruction-contract change affects existing callers.
task.txtnote 4 previously told an agent to specify what the subagent should return "in its final and only message to you." A task can now produce more than one answer, so that promise is retired. Any caller prompt written against "final and only" is affected.Two upstream PRs merged into this area while this work was in progress. #43657 and #43821 fixed two cases where a failed child read as an empty success, and #42725 covered the same ground and is now closed. Both of those checks survive here unchanged and in the same position, and their tests pass unmodified.
No exported name is removed, but two type changes reach direct
packages/corecallers at compile time:Info.outputwidens fromstringtounknown, and theruneffect handed tostartandextendnow returns a structured outcome instead of a string.Over HTTP, eight more operations declare
SessionBusyError, bringing it to twelve, so a session refusing work because its branch is closing surfaces as a typed 409 on those endpoints instead of falling through to a generic 500. The error class itself already exists upstream at 409 and is unchanged; what is new is declaring it on the operations where that refusal can actually occur.That mapping is a deliberate choice and worth flagging as one. A 500 is indistinguishable from a genuine fault, so a caller cannot tell "busy, retry shortly" from "this is broken" — and a branch-closing refusal is an expected outcome, not an unexpected one.
session.abortgains a 500 alongside its existing 200 and 400, and two paths that accept caller-supplied part metadata gain a 400.Full API surface delta
The background job service interface grows from 8 methods to 21, most of them exact-identity variants of operations that previously took a reusable id, and
Info.notesis added. Eight operations newly declareSessionBusyError, bringing that error to twelve in total; the abort 500 isSessionClosureError, with five closed reasons. Regenerated SDK and OpenAPI output is included; it was checked by parsing the emitted document and enumerating operations, not by reading the diff.Answer ordering keys on the final message's creation time, with the message id only breaking ties, because message ids carry a time component that wraps and so cannot order across a wrap boundary on their own.
task_noticelines are each collapsed to one line,<-escaped and length-capped, so a notice cannot nest an envelope inside itself.Same-
task_idadmission ordering is deliberately not behind the experimental flag. Calling with thetask_idof a live run already extends that run, flag or not, so the order of two simultaneous calls is already undefined on the default path. It is now defined. Single-call usage is unaffected.What this does not cover:
Session.removedoes not first request coordinated branch closure. Those paths are improved, not replaced.Runner.ensureRunningreturns the in-flight run's deferred and drops the work it was handed, so a callback delivered while the parent is mid-run does not itself start a turn. The message is still persisted and is read at the next history reload; what it does not do is interrupt. That file is byte-identical to upstream and the caller's wait does not depend on it.One thing carries real risk:
Session.forkis unchanged by this PR, but its interaction with branch closure is not exercised by any test here.packages/webcarries its own copy of the closure classifier rather than importing from core, matching how that package is already structured. A test compares the two files directly and fails if they drift apart, so the duplication is enforced rather than merely intended.Reviewing this
The first commit stands alone: cancellation acts on the run it observed rather than whatever currently holds that id —
packages/core/src/background-job.tsand its two call sites, with its own test. It fixes a default-path bug and depends on nothing else here.The rest is ordered but not independent. Each group compiles on its own, and its suites were run against the upstream base at that point rather than only at the end, so a stacked series is workable; what does not work is splitting it into separate PRs that land in any order. Holding a caller's answer until its children finish needs somewhere to record what finished, which is the closure and attachment machinery; the wait without it produces a caller that waits forever. Widening the error channel in session run-state cascades to the prompt loop and then to the HTTP handlers, with no intermediate state that compiles. And
tool/task.tsis a single file that three of these concerns rewrite, which is why it was rebuilt from upstream's version rather than patched — separate patches would conflict at nearly every hunk.If a stacked series or a different grouping would review better, the commits are already arranged along those seams.
A note on this work
Fixing and improving Task has proved to be complex, demanding work. Working with agents to get the systems design right was frustrating. Much that seemed obvious once I understood the constraints and requirements, agents struggled with. I seem to have hit a very jagged edge in the geometry of model intelligence with this PR. Even the Issue/PR description proved a challenge to get agents to produce. There is something about the complexity and scope of these Task changes that current frontier models really struggled with.
I do not claim this PR's changes are perfect, but much effort was expended, with 33 days of design, implementation, and field testing to find and fix bugs. I worked on this as a primary focus among a group of related features in my harness and plugins, with all of my time each day going to these coding projects. I would not recommend trying to redo this work using current models. It was a very frustrating experience, that at times tried my sanity (many late nights; no good for mental health). I think one would be hard pressed to do better, and even achieving better, the gains would be limited.
My recommendation to other users is to audit and test the design and code to improve operational efficiency and find and fix bugs; to build from the foundations set by this PR and suggest redesign only where truly warranted. I think a lot of time and effort would be wasted trying to design something 'better'. There are major defects in Task which need fixing, this PR fixes many of them, and it's part of a larger roadmap of changes I'm working on. I have other improvements to OpenCode, including more related to Task, which I'm refraining from submitting until it is clearer whether or not upstream maintainers are interested in merging my contributions.
After this my next set of changes for OpenCode will be adding multi-caller capabilities and letting one project's session Task another project's agents via an 'interlink' Task parameter (schema: sync, async, interlink (when agent in project A Tasks an agent in Project B), intercom (when a caller-agent does a task_id resume to a session it is not the parent of)).
I am building these features as an Agentic Collaboration Framework fork, which I hope upstream will be interested in merging.
Screenshots / recordings
Two visual changes.
backgroundreads asasyncin the footer command, the keybind hint and the task card subtitle. And a stopped branch leaves a[Branch closure]record row in the transcript, where the task card previously kept spinning on work that had already died.Checklist