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)}`)
|
||||
|
||||
@@ -853,3 +853,57 @@ describe('LocalTaskService.onTasksChanged', () => {
|
||||
expect(seen).toEqual([1, 2, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalTaskService teardown change notifications', () => {
|
||||
it('announces the stopping transition during owner teardown, before settlement', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'alice')
|
||||
ctx.agents.register(owner)
|
||||
const p = producer({ owner })
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
|
||||
const statuses: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged((changed) => {
|
||||
statuses.push(changed === undefined ? undefined : ctx.tasks.list(changed)[0]?.status)
|
||||
})
|
||||
|
||||
// A slow producer keeps teardown parked between cancel and settlement;
|
||||
// an observer must not be left showing `running` for that whole window.
|
||||
const disposal = disposeAgentScope(owner)
|
||||
await tick()
|
||||
expect(statuses).toEqual(['stopping'])
|
||||
|
||||
p.settle({ status: 'killed' })
|
||||
await disposal
|
||||
// Settlement, then the removal that empties the visible set.
|
||||
expect(statuses).toEqual(['stopping', 'killed', undefined])
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
void id
|
||||
})
|
||||
|
||||
it('announces the emptied set to a listener registered outside this service (reload safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(LocalTaskService)
|
||||
ctx.tasks.attachSurface('test-surface')
|
||||
|
||||
// The api-proxy carrier registers from its own stream context, not the
|
||||
// registry's fiber, so it is still listening when the registry unloads.
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'sleep 600',
|
||||
run: () => ({
|
||||
cancel() { settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
|
||||
}),
|
||||
})
|
||||
seen.length = 0
|
||||
|
||||
await fiber.dispose()
|
||||
// stopping (teardown cancel), settlement, then the final empty set.
|
||||
expect(seen).toEqual([undefined, undefined, undefined])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tasks/README.md
|
||||
README.md: 8ad124531daf74f1d44ff46aaaad9b9b860b07c8
|
||||
README.zh.md: c1452d4610c65fa66c5ba2f2939b8c501e92bd7a
|
||||
README.md: 71da4cbd189c3cd2aac51bfb004d2dd774e081c2
|
||||
README.zh.md: 283e3dd3aee1c86c856f056156ebe12caff276ae
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。
|
||||
- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。
|
||||
- `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。
|
||||
- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。
|
||||
- `onTasksChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。
|
||||
- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。
|
||||
|
||||
有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。
|
||||
|
||||
@@ -134,8 +134,14 @@ export abstract class TaskService extends Service {
|
||||
/**
|
||||
* Register an effect-scoped observer of visible-set changes. It fires after
|
||||
* every commit that changes what {@link list} returns for that owner —
|
||||
* registration, the stopping transition, settlement, and owner-disposal
|
||||
* removal — so an observer re-reads rather than accumulating deltas.
|
||||
* registration, every stopping transition (including the one teardown
|
||||
* performs before it awaits a slow producer), settlement, owner-disposal
|
||||
* removal, and the emptying that service disposal commits — so an observer
|
||||
* re-reads rather than accumulating deltas.
|
||||
*
|
||||
* The registration binds to the CALLING fiber, so an observer mounted outside
|
||||
* this service still receives the disposal emptying; that is what stops a
|
||||
* consumer from retaining rows after the registry unloads.
|
||||
*
|
||||
* This is not a superset of {@link onTaskDone}: that one delivers the terminal
|
||||
* record under first-wins semantics a control surface couples to notice
|
||||
|
||||
Reference in New Issue
Block a user