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
@@ -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
@@ -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.
@@ -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 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
+4 -1
View File
@@ -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,
+2 -2
View File
@@ -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
+8 -2
View File
@@ -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
+8 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -21,4 +21,4 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理
## Known Limitations and Deferred Work
- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。
- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型
- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致
@@ -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;
}
@@ -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 ? <StateDot state="ongoing" className={css.triggerDot} /> : null}
<span className={css.count}>{countLabel}</span>
@@ -167,7 +174,7 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp
<StateDot state={dotState(task.status)} className={css.rowDot} />
<span className={css.kind}>{task.kind}</span>
<span className={css.label} title={task.label}>{task.label}</span>
<span className={css.status}>{task.detail ?? status}</span>
<span className={css.status} title={task.detail ?? status}>{task.detail ?? status}</span>
<span
className={css.duration}
title={t(live ? 'duration.title.live' : 'duration.title.done', { duration })}
+8
View File
@@ -3111,6 +3111,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}),
ctx.on('session/created', (session: Session) => {
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)
@@ -29,15 +29,19 @@ type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
*/
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<TaskOutcome>((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')
})
})
@@ -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',
+11 -4
View File
@@ -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 -2
View File
@@ -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
+1 -1
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.
+1 -1
View File
@@ -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 的任务向调用方开放,并持续到服务释放。
+8 -2
View File
@@ -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