diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml index f00211b5f4..9984adfa0f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.md -2026-08-08-web-background-task-display.md: 17ac109082ab3b2afea92194d0d148cf11ba9d0a -2026-08-08-web-background-task-display.zh.md: 1049218337caae3bd95c6f6ff3183589d0ce9d15 +2026-08-08-web-background-task-display.md: 558d0e26b3b8da83602296c5fdd0944886d4eab7 +2026-08-08-web-background-task-display.zh.md: 9c9f4643bb08c748381ede6bf585858ca1204ff7 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md index 17ac109082..558d0e26b3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md @@ -125,7 +125,7 @@ Below it, [`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spe **Unowned-task fan-out is easy to under-implement.** Pushing only to the changed owner's session is correct for owned tasks and silently wrong for unowned ones, which are visible everywhere. The bug would surface only in compositions that create unowned tasks, which is why the carrier suite covers it directly. -**The UI set is not the registry's set.** An unowned task is invisible in the header while `task_list` still reports it to the model. In practice every tool call carries an agent, so this stays theoretical, but the two surfaces are not interchangeable. +**The UI set is not the registry's set.** The header shows what one session can see, so a task owned by another session never appears in it even though the registry holds it — and because the registry is process-local, a restart empties every list while the transcript still shows the `run_in_background` cards that started them. Unowned tasks are the opposite case: they reach every session's list, exactly as `list(caller)` reports them to every caller. **Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md index 1049218337..9c9f4643bb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md @@ -125,7 +125,7 @@ abstract onTasksChanged(listener: TasksChangedListener): () => void **无主任务的扇出很容易做漏。** 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。 -**UI 的集合不等于注册表的集合。** 无主任务在 header 里不可见,而 `task_list` 仍会把它报告给模型。实践中每次工具调用都带着 agent,所以这停留在理论层面,但两个界面并不可互换。 +**UI 的集合不等于注册表的集合。** header 显示的是「一个会话能看到什么」,所以别的会话拥有的任务在这里永远不出现,尽管注册表里有它;而由于注册表是进程本地的,一次重启会清空所有列表,transcript 里那些启动它们的 `run_in_background` 卡片却还在。无主任务是反过来的情形:它们会进入每一个会话的列表,正如 `list(caller)` 对每个调用方都报告它们。 **终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 diff --git a/apps/web/tests/background-task-list.e2e.ts b/apps/web/tests/background-task-list.e2e.ts index 217eda49fa..734d43131a 100644 --- a/apps/web/tests/background-task-list.e2e.ts +++ b/apps/web/tests/background-task-list.e2e.ts @@ -82,8 +82,11 @@ describe.skipIf(MODE === 'record')('web e2e: background task list', () => { it('shows a running background task in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running')) + // Point assertion, not a poll: `expect.poll` retries until a predicate + // holds, so polling for zero passes at t=0 and proves nothing. The + // "renders nothing without a task" branch is owned by the component suite. const trigger = page.getByRole('button', { name: '1 background task running' }) - await expect.poll(() => trigger.count(), { timeout: 5_000 }).toBe(0) + expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index bd83fc1895..e6444d9b1a 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.i18n.yaml @@ -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 docs/subsystems/tasks.md -tasks.md: fd4041698de2fd19156c2a87d378d7fafdd1d15a -tasks.zh.md: 00c8aab2794f99678e9707d2dfb7c67bf411b24d +tasks.md: 2ee062e1c410f7a26af273ca9adc70a978f25563 +tasks.zh.md: 9b6ea8260e439eca2ffffa3b50fb030f117c495c diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index fd4041698d..2ee062e1c4 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -248,8 +248,14 @@ abstract onTaskDone(listener: TaskDoneListener): () => void /** * 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 diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 00c8aab279..9b6ea8260e 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -248,8 +248,14 @@ abstract onTaskDone(listener: TaskDoneListener): () => void /** * 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 diff --git a/packages/client/ui-task/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml index 45157f12e5..39f800e04b 100644 --- a/packages/client/ui-task/README.i18n.yaml +++ b/packages/client/ui-task/README.i18n.yaml @@ -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/client/ui-task/README.md -README.md: 318984808838cb7a336a4c34c95a2f27db578423 -README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0 +README.md: a1430db55c7519c612e5d39de4627c750976d2d5 +README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md index 3189848088..a1430db55c 100644 --- a/packages/client/ui-task/README.md +++ b/packages/client/ui-task/README.md @@ -21,4 +21,4 @@ None; the package never assembles or sends provider requests. ## Known Limitations and Deferred Work - **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running. -- **The list is not the registry's own set** — it shows what the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model. +- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a task owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those tasks. An unowned task (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller. diff --git a/packages/client/ui-task/README.zh.md b/packages/client/ui-task/README.zh.md index 7adeb25466..5e29c93980 100644 --- a/packages/client/ui-task/README.zh.md +++ b/packages/client/ui-task/README.zh.md @@ -21,4 +21,4 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理 ## Known Limitations and Deferred Work - **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 -- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。 +- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-task/src/client/TaskListAction.module.css b/packages/client/ui-task/src/client/TaskListAction.module.css index 6bf6ed2507..b6ac05aa31 100644 --- a/packages/client/ui-task/src/client/TaskListAction.module.css +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -111,6 +111,15 @@ line-height: 18px; } +/* A failed task's detail is the producer's raw error text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + .duration { font-variant-numeric: tabular-nums; } diff --git a/packages/client/ui-task/src/client/TaskListAction.tsx b/packages/client/ui-task/src/client/TaskListAction.tsx index 722d317aa9..ede2eca377 100644 --- a/packages/client/ui-task/src/client/TaskListAction.tsx +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -148,7 +148,14 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp className={css.trigger} aria-expanded={open} aria-label={countLabel} - onClick={() => { setOpen(current => !current) }} + onClick={() => { + // Sample the clock in the same commit that opens the list: the + // mount-time value predates every task, so the first painted frame + // would otherwise clamp a long-running row to zero until the + // open effect corrects it a frame later. + setNow(Date.now()) + setOpen(current => !current) + }} > {liveCount > 0 ? : null} {countLabel} @@ -167,7 +174,7 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp {task.kind} {task.label} - {task.detail ?? status} + {task.detail ?? status} { subscribeSession(queue, session) + // The subscribe frame clears the client's task mirror, and a + // session born after the stream opened missed the baseline loop. + // Unowned tasks are visible to it from birth, so without this it + // would show none until the next registry change. + const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) diff --git a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts index 1af65962ca..ebfe44d375 100644 --- a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -29,15 +29,19 @@ type TaskFrame = Extract */ function producer(label = 'sleep 60') { let settle!: (outcome: TaskOutcome) => void + // A stream producer, so the carrier CAN consume the cursor if it ever calls + // `read()`; `reads` is what proves it never does. + const reads = { count: 0 } const spec = { kind: 'bash' as const, label, run: () => ({ cancel: () => {}, done: new Promise((resolve) => { settle = resolve }), + readOutput: () => { reads.count += 1; return 'stolen output' }, }), } - return { spec, settle: (outcome: TaskOutcome) => { settle(outcome) } } + return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } } } async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { @@ -204,3 +208,56 @@ describe('session/tasks without the registry', () => { expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) }) }) + +describe('session/tasks never consumes model output', () => { + it('drives the whole lifecycle without calling the single consuming cursor', async () => { + // `ctx.tasks.read()` consumes the one output cursor, so a carrier read + // silently takes bytes the model's `task_output` will never see. The + // failure is invisible at the call site, which is why this asserts the + // count rather than trusting review. + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + await collected + + expect(p.reads.count).toBe(0) + }) + + it('reads nothing while minting the subscription baseline either', async () => { + const { ctx, agent } = await harness(true) + const p = producer() + ctx.tasks.start({ ...p.spec, owner: agent }) + + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + + expect(baseline?.tasks).toHaveLength(1) + expect(p.reads.count).toBe(0) + }) +}) + +describe('session/tasks baseline for a session born after the stream opened', () => { + it('carries the already-visible unowned set to the new session', async () => { + const { ctx } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal) + + // One unowned task exists before the new session is created; the subscribe + // frame clears the client mirror, so the baseline has to follow it. + ctx.tasks.start(producer('visible to every caller').spec) + const created = ctx.sessions.create() + + const frames = await collect(stream, 2, abort) + const forNew = frames.filter(frame => frame.sessionId === created.id) + expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller') + }) +}) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 8fa9d37599..c8c217c5bd 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1126,7 +1126,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void', - jsDoc: '/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, the stopping transition, settlement, and owner-disposal\n * removal — so an observer re-reads rather than accumulating deltas.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', + jsDoc: '/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, every stopping transition (including the one teardown\n * performs before it awaits a slow producer), settlement, owner-disposal\n * removal, and the emptying that service disposal commits — so an observer\n * re-reads rather than accumulating deltas.\n *\n * The registration binds to the CALLING fiber, so an observer mounted outside\n * this service still receives the disposal emptying; that is what stops a\n * consumer from retaining rows after the registry unloads.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', }, { signature: 'abstract attachSurface(name: string): () => void', diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 947ddec004..5b52d90539 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -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)}`) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index ebd1e73b52..1fa0323fec 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -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((resolve) => { settle = resolve }), + }), + }) + seen.length = 0 + + await fiber.dispose() + // stopping (teardown cancel), settlement, then the final empty set. + expect(seen).toEqual([undefined, undefined, undefined]) + }) +}) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index da255745ad..628874e038 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/README.i18n.yaml @@ -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 diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 8ad124531d..71da4cbd18 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -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. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index c1452d4610..283e3dd3ae 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -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 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 600f157660..1abc750564 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -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