fix(tasks): correct the change-feed contract and its documentation

Review found the `onTasksChanged` teardown reasoning inverted. The comment
claimed every registration is an effect on the registry's own fiber, so
listeners would be gone before service disposal empties the store — but the
traceable proxy rebinds `this.ctx` to the CALLER, which this package's own
HMR-safety test already proves. The only shipped consumer registers from the
api-proxy mux stream, so it was still listening and simply kept the rows it
last received. Service disposal now announces the emptied set, and teardown
announces its stopping transition immediately instead of leaving an observer
on `running` for however long a slow producer takes to release.

Two documentation claims were false in the opposite direction: the Agent Note
and the ui-task README both said an unowned task is invisible in the header,
while `list(caller)` returns unowned tasks to every caller, the carrier fans
their changes out to every subscribed session, and this PR's own test asserts
exactly that. The note even contradicted itself two sections earlier. Both
sides now state the real asymmetries — another session's tasks, and the
process-local registry emptying on restart.

The "no Web path calls the consuming `ctx.tasks.read()`" invariant claimed a
test that did not exist; the carrier suite's producer had no `readOutput` at
all, so a stray read would have failed nothing. Its producer now counts cursor
consumption and the lifecycle and baseline paths both assert zero.

Also: a session created after the mux opened now receives the task baseline it
missed, the popover samples its clock when it opens rather than at mount, and
a failed task's unbounded producer detail elides instead of widening the row.
This commit is contained in:
Yichen Jiang
2026-08-10 13:37:18 +08:00
parent 0a0a75730f
commit 15d452d7ea
21 changed files with 192 additions and 29 deletions

View File

@@ -12,7 +12,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService`
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter.
- `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited.
- `onTasksChanged(listener)` observes visible-set changes — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported.
- `onTasksChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying.
- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached.
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.