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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)`;一次结算也只会抵达其所有者所属组合注册的监听器。
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user