Skip to content

fix(task): make async subagent tasks answer honestly, once, in order, and stop - #45482

Open
NamedIdentity wants to merge 89 commits into
anomalyco:devfrom
NamedIdentity:task-lifecycle
Open

fix(task): make async subagent tasks answer honestly, once, in order, and stop#45482
NamedIdentity wants to merge 89 commits into
anomalyco:devfrom
NamedIdentity:task-lifecycle

Conversation

@NamedIdentity

Copy link
Copy Markdown

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

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

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_id extends 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. background versus foreground names 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.background command name, the background: true metadata field and BackgroundJob itself are deliberately unchanged, which is why background still appears throughout the diff. The one API text that did change is the session.background description, 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:

<task id="{sessionID}" state="{running|completed|error|cancelled}">
<summary>…</summary>
<task_notice>…</task_notice>
<task_result>
{the child's answer}
</task_result>
</task>

The inner tag follows the state: task_result for a completed answer, task_error for a failure, and task_status for a receipt on work still running. Upstream wraps a still-running receipt in task_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_id for 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_notice lines 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 failed toast in the TUI and an error line in opencode run. Success stays silent. Undo and redo run only after a successful abort of the session's branch, and show Undo failed or Redo failed when that abort does not succeed, rather than proceeding against work that did not stop.

Cancellation records carry opencode.branch_closure in 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, the part.update route 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_SUBAGENTS appears in six production files. It gates the async parameter and its protocol, some task lease branches, the session.background endpoint, 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 run prompt 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 both interrupted === true and 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 at 3a31c4ea80 across nine runs — eight packages/opencode shards plus packages/core.

pristine 3a31c4ea80 this branch
pass 4,420 5,122
fail 13 12
skip 65 65

Failure 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 run failed 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-ui and 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 gate serve and --version are 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 app and enterprise a 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 --version nor serve paints.

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, the part.update route 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

background still 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.txt note 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/core callers at compile time: Info.output widens from string to unknown, and the run effect handed to start and extend now 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.abort gains 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.notes is added. Eight operations newly declare SessionBusyError, bringing that error to twelve in total; the abort 500 is SessionClosureError, 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_notice lines are each collapsed to one line, <-escaped and length-capped, so a notice cannot nest an envelope inside itself.

Same-task_id admission ordering is deliberately not behind the experimental flag. Calling with the task_id of 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:

  • Answer ordering rests on an invariant that is documented rather than enforced. The chronology key orders the log, and arrival order is in-order because runs of one child session are serialized by the runner and a sequence's detect-to-file span contains no await on an external event. A future edit introducing a real await into that span would reopen an ordering inversion with no test failing.
  • No execution timeout. A failed child releases normally; a genuinely hung one can still hold its caller's return.
  • Branch cancellation is process-local — one instance and project directory. No distributed execution, no crash recovery; a hard crash loses in-memory repair state.
  • The public abort route uses the closure coordinator, but a synchronous task's internal abort still reaches the older sweep, and Session.remove does not first request coordinated branch closure. Those paths are improved, not replaced.
  • The result path is not a formal exactly-once protocol. It elects one observer and delivers each answer once; when coordination degrades it reports uncertainty rather than stopping healthy work to enforce a stronger claim.
  • Runner.ensureRunning returns 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.fork is unchanged by this PR, but its interaction with branch closure is not exercised by any test here.

packages/web carries 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.ts and 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.ts is 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. background reads as async in 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

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Sean Smith 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.
@github-actions

Copy link
Copy Markdown
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.

Sean Smith 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.
@NamedIdentity

Copy link
Copy Markdown
Author

Notice - I'm tracking down some bugs in this PR.

Sean Smith 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
@NamedIdentity

Copy link
Copy Markdown
Author

bugs found, fixed, pushed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle: the tool misdescribes async work, results go missing, and stops leave work running

1 participant