fix(tasks): harden lifecycle and bundle config

This commit is contained in:
Yichen Jiang
2026-07-12 16:00:56 +08:00
parent e19043881a
commit bd9abba638
17 changed files with 295 additions and 58 deletions

View File

@@ -23,8 +23,9 @@
* belongs to its owning agent and producing backend, not to the tool plugin
* whose call started it, so an HMR reload of a producer or of the control
* surface never orphans or kills a running task. The registry's own disposal
* cancels every live task and awaits settlement — no orphans survive
* `fiber.dispose()`.
* cancels every live task and awaits contract-compliant producers to
* quiescence. If a teardown cancel throws, the registry force-fails its record
* to avoid deadlock and logs that the underlying work may be orphaned.
*
* @module @deepseek-ai/dsh-tasks
*/
@@ -77,7 +78,7 @@ interface TrackedTask {
reported: boolean
/** Resolves once the terminal snapshot is recorded and listeners notified. */
settled: Promise<void>
/** Resolver for {@link settled} (called exactly once, by {@link TaskService.settle}). */
/** Resolver for {@link settled} (called by the first effective {@link TaskService.settle}). */
markSettled: () => void
/** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */
waiters: number
@@ -98,8 +99,8 @@ export class TaskService extends Service {
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
private listenersClosed = false
/** Owner agents that already have this registry's cleanup attached. */
private ownerCleanups = new Set<AgentId>()
/** Owner agents whose cleanup effect is attached, mapped to its self-detacher. */
private ownerCleanups = new Map<AgentId, () => void>()
/**
* The service's OWN construction-time context, for work that outlives the
* calling fiber: detached settlement continuations (logging), and the
@@ -338,8 +339,8 @@ export class TaskService extends Service {
}
/**
* Register a completion listener, called exactly once per task with the
* terminal snapshot. Effect-scoped (disposed with the calling fiber);
* Register a completion listener, called exactly once per terminal task
* record with its snapshot. Effect-scoped (disposed with the calling fiber);
* per-listener containment (one throwing listener is logged, never starves
* the rest); never fires after this service is disposed.
* @param listener - called with each settling task's terminal snapshot.
@@ -409,13 +410,17 @@ export class TaskService extends Service {
}
/**
* Record a task's terminal outcome (called exactly once — the single `done`
* continuation is the only caller), notify listeners with containment, then
* release waiters. A settlement observed by a pending {@link wait} marks
* the task reported BEFORE listeners run, so the notice surface can
* Record the first terminal outcome, notify listeners with containment, then
* release waiters. Normally the producer's single `done` continuation calls
* this; teardown also force-fails the record when `cancel` throws and `done`
* may never settle. First-wins makes a producer outcome arriving after that
* fallback a no-op, so listeners fire once and the diagnosed terminal state
* is never overwritten. A settlement observed by a pending {@link wait}
* marks the task reported BEFORE listeners run, so the notice surface can
* suppress its redundant "finished".
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
task.status = outcome.status
task.detail = outcome.detail
task.output = outcome.output
@@ -439,27 +444,38 @@ export class TaskService extends Service {
* the agent's disposal chain drains (`ctx.agents.drainCleanups`), the
* owner's still-live tasks are cancelled, awaited to settlement, and their
* snapshots dropped. Registered through {@link selfCtx} so the cleanup
* survives producer-plugin reloads. Fails loud when no agent registry is
* mounted — an owned background task without the cleanup seam would outlive
* its owner silently.
* survives producer-plugin reloads. When the cleanup starts, it detaches its
* own effect before awaiting task settlement, so completed owners do not
* accumulate effect wrappers (and captured sessions) on the long-lived tasks
* fiber. A narrow race remains if new work starts on an agent already being
* drained: before this callback clears the owner entry, that start can reuse
* the in-flight cleanup after its task snapshot was taken.
* Fails loud when no agent registry is mounted — an owned background task
* without the cleanup seam would outlive its owner silently.
*/
private ensureOwnerCleanup(owner: Agent): void {
if (this.ownerCleanups.has(owner.id)) return
const ownerId = owner.id
if (this.ownerCleanups.has(ownerId)) return
const agents = this.selfCtx.get('agents')
if (agents === undefined) {
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
}
const ownerSession = owner.session.header.id
// Attach FIRST, record after: onCleanup throws for an unregistered agent,
// and marking the owner as covered before that would make every later
// registration for the same owner silently skip the cleanup.
agents.onCleanup(owner.id, async () => {
this.ownerCleanups.delete(owner.id)
await this.disposeOwned(owner.session.header.id)
const detach = agents.onCleanup(ownerId, async () => {
const disposeEffect = this.ownerCleanups.get(ownerId)
this.ownerCleanups.delete(ownerId)
// A drain racing lifecycle teardown may find that the effect was already
// detached; otherwise this removes its wrapper from the tasks fiber now.
disposeEffect?.()
await this.disposeOwned(ownerSession)
})
this.ownerCleanups.add(owner.id)
this.ownerCleanups.set(ownerId, detach)
}
/** Cancel (contained), await, and drop every task owned by one session. */
/** Cancel, await terminal records, and drop every task owned by one session. */
private async disposeOwned(ownerSession: string): Promise<void> {
const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession)
this.cancelForTeardown(owned, 'owner disposed')
@@ -469,8 +485,10 @@ export class TaskService extends Service {
/**
* Service teardown: close the listener registry FIRST (late completions
* from teardown kills stay silent), cancel every live task, and await
* quiescence. No orphan child work survives the tasks fiber.
* from teardown kills stay silent), cancel every live task, and await each
* terminal record. Contract-compliant producers settle at quiescence; a
* producer whose cancel throws is force-failed so disposal cannot deadlock,
* with the possible underlying orphan logged explicitly.
*/
private async disposeAll(): Promise<void> {
this.listenersClosed = true
@@ -483,18 +501,25 @@ export class TaskService extends Service {
/**
* Teardown-path cancellation with per-task containment: unlike the
* model-facing {@link kill} (where a throwing producer `cancel` should fail
* the tool call loudly), a teardown must reach quiescence past a broken
* producer, so a throw is logged and the sweep continues.
* model-facing {@link kill} (where a throwing producer `cancel` fails the tool
* call and leaves the record live), teardown force-fails a record whose cancel
* throws because its `done` may depend on a request that never arrived. This
* prevents disposal deadlock but cannot prove the underlying work stopped, so
* the potential orphan is carried in the detail and warning. A cancel that
* returns but never leads to `done` remains indistinguishable from a slow stop
* and can still stall teardown; fixing that requires a separate bounded-lifetime
* or forced-disposal design.
*/
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {
if (isTerminal(task.status)) continue
task.status = 'stopping'
try {
task.cancel(reason)
task.status = 'stopping'
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown: ${String(error)}`)
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)}`)
this.settle(task, { status: 'failed', detail })
}
}
}