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:
@@ -369,11 +369,14 @@ export class LocalTaskService extends TaskService {
|
||||
const all = [...this.store.values()]
|
||||
this.cancelForTeardown(all, 'tasks service disposed')
|
||||
await Promise.all(all.map(task => task.settled))
|
||||
// Distinct owners whose records just disappeared. `onTasksChanged` binds to
|
||||
// the CALLING fiber (the traceable proxy rebinds `this.ctx`), so a consumer
|
||||
// mounted outside this service — the api-proxy carrier reads `ctx.get` from
|
||||
// the mux stream — is still listening here. Without this it keeps the rows
|
||||
// it last received after a registry reload.
|
||||
const emptied = new Set(all.map(task => task.owner))
|
||||
this.store.clear()
|
||||
// No change notification here: every `onTasksChanged` registration is an
|
||||
// effect on this service's own fiber, so the listeners are already gone by
|
||||
// the time service teardown reaches this line. An observer learns the
|
||||
// registry left through its own disposal, not through a final empty set.
|
||||
for (const owner of emptied) this.notifyChanged(owner)
|
||||
this.changeListeners.clear()
|
||||
// Detach cross-fiber owner effects after the shared store is quiescent.
|
||||
const ownerCleanups = [...this.ownerCleanups.values()]
|
||||
@@ -392,6 +395,10 @@ export class LocalTaskService extends TaskService {
|
||||
try {
|
||||
task.cancel(reason)
|
||||
task.status = 'stopping'
|
||||
// Teardown reaches settlement only after the producer releases, which a
|
||||
// slow stop can defer; announcing the transition here is what keeps an
|
||||
// observer from showing `running` for that whole window.
|
||||
this.notifyChanged(task.owner)
|
||||
} catch (error: unknown) {
|
||||
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
|
||||
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`)
|
||||
|
||||
Reference in New Issue
Block a user