Merge commit '5a0d28e200d89dca2b8004d9cd624629a1409a3f' into codex/bounded-background-tasks-v2

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
This commit is contained in:
pku-xht
2026-08-11 22:28:38 +08:00
422 changed files with 15607 additions and 1120 deletions

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-local/README.md
README.md: c1fa91cd837ef9ad6ef36b672c874bc9d64edc67
README.zh.md: f521b2714c3848756628510df85f5cf98731b162
README.md: b486438eb2f73728361a6140fdec33603a2ace45
README.zh.md: ecc9104e4145add9448d43e1eb2565a3fc7e09c3

View File

@@ -16,7 +16,7 @@ Tasks belong to their owner and backend, not the producer tool fiber, so produce
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion reporters do not duplicate notices.
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, releases waiters, and notifies listeners once with per-listener containment. Pending waits mark the task reported before listeners run so completion reporters do not duplicate notices, and a teardown cancel marks it for the same reason: nothing will read a notice addressed to an owner being destroyed. Completion is the last thing a settlement announces, after the record is committed and the visible-set change is published, because a reporter may open a model turn synchronously and every other observer must already have seen the settled record.
Controllers and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered.

View File

@@ -16,7 +16,7 @@
服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常,服务会强制将记录标为失败,并警告工作可能成为孤立工作,而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知。
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,随后释放等待方,再只通知监听器一次;各监听器的故障会单独隔离。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知;销毁时的取消出于同样的理由也会标记:面向正在被销毁的所有者的通知不会有人读到。完成是一次结算最后才宣布的事情,排在记录提交与可见集变更发布之后,因为报告方可能同步开启一个模型轮次,而该结算的其他所有观察者都必须已经看到已结算的记录。
控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。

View File

@@ -253,11 +253,12 @@ export class LocalTaskService extends TaskService {
}
const onAbort = (): void => {
task.waitResolvers.delete(onSettled)
// A settled task cannot reach here: settlement releases every waiter
// before it announces completion, and each released waiter detaches
// this listener in the same synchronous span, so nothing that reacts
// to a settlement can abort a wait the settlement already owed.
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement suppressed the notice for this waiter; deliver it.
resolve()
} else {
uncount()
reject(new Error('wait aborted'))
@@ -402,9 +403,12 @@ export class LocalTaskService extends TaskService {
}
/**
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
* settlement. Pending waits mark the task reported before listeners run.
* Record the first terminal outcome, release waiters, then announce
* completion. First-wins preserves a teardown force-failure against late
* producer settlement. Pending waits mark the task reported before listeners
* run. Completion is announced last because a reporter may open a model turn
* synchronously: every other observer of this settlement must already have
* seen the committed record.
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
@@ -413,24 +417,23 @@ export class LocalTaskService extends TaskService {
task.output = outcome.output
task.finishedAt = Date.now()
if (task.waiters > 0) task.reported = true
if (!this.listenersClosed) {
const snapshot = this.snapshot(task)
for (const listener of this.listenersFor(task.owner)) {
try {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
const snapshot = this.snapshot(task)
const waitResolvers = [...task.waitResolvers]
task.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
task.markSettled()
this.notifyChanged(task.owner)
if (this.listenersClosed) return
for (const listener of this.listenersFor(task.owner)) {
try {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
/**
@@ -501,6 +504,14 @@ export class LocalTaskService extends TaskService {
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {
if (isTerminal(task.status)) continue
// Teardown cancellation is a kill without a caller, so it claims the
// terminal report the same way `kill()` does. Nothing will read a notice
// for a task whose owner or service is being destroyed, and a waking
// reporter would spend a model request per teardown layer. This is
// decided before the producer runs: the force-failure below settles the
// record too, so a throwing cancel must not be the one path that
// announces an unreported completion into a disposing owner.
task.reported = true
try {
task.cancel(reason)
task.status = 'stopping'

View File

@@ -539,8 +539,9 @@ describe('LocalTaskService.wait', () => {
const ctx = await harness()
const controller = new AbortController()
const seen: TaskSnapshot[] = []
// The listener aborts after settlement has assigned delivery to this waiter
// but before its resolve microtask; the waiter must still receive the result.
// The listener aborts after settlement released this waiter but before its
// resolve microtask runs. Releasing waiters ahead of the announcement is
// what makes that abort harmless; this is the guard on that ordering.
ctx.tasks.onTaskDone((snapshot) => {
seen.push(snapshot)
controller.abort()
@@ -688,6 +689,51 @@ describe('LocalTaskService owner cleanup', () => {
expect(ctx.tasks.list(owner)).toEqual([])
})
it('publishes the settled visible set before announcing completion', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const p = producer({ owner })
ctx.tasks.start(p.spec)
// Registered after start so only the settlement's notifications are ordered.
const order: string[] = []
ctx.tasks.onTasksChanged(() => void order.push('changed'))
ctx.tasks.onTaskDone(() => void order.push('done'))
p.settle({ status: 'completed' })
await tick()
// A completion reporter may open a turn synchronously. Announcing before
// the visible set is published would let a client render that turn while
// its task row still reads `running`.
expect(order).toEqual(['changed', 'done'])
})
it('reports a teardown-cancelled record so completion reporters stay quiet', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.start({
kind: 'subagent',
label: 'long research',
owner,
run: () => ({
cancel() { settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
}),
})
// Observers still receive the terminal record; the report bit is what
// keeps a notice reporter from addressing an owner being destroyed.
await disposeAgentScope(owner)
expect(seen).toHaveLength(1)
expect(seen[0]?.reported).toBe(true)
})
it('attaches one cleanup per owner and drains all owned tasks with the scope', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')