fix(tasks): contain async completion listeners
TaskService contained synchronous listener throws but discarded returned promises. An async TaskDoneListener could therefore reject as an unhandled process rejection even though the API promises per-listener containment. Allow listeners to return PromiseLike<void> and attach a rejection handler to each result without awaiting it. This logs asynchronous failures independently while preserving the observation-only contract: later listeners, task waiters, and teardown are not delayed. Add a regression test and synchronize the public docs and generated API declaration.
This commit is contained in:
@@ -939,7 +939,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'TaskDoneListener',
|
||||
declaration: 'export type TaskDoneListener = (snapshot: TaskSnapshot, owner: Agent | undefined) => void;',
|
||||
declaration: 'export type TaskDoneListener = (snapshot: TaskSnapshot, owner: Agent | undefined) => void | PromiseLike<void>;',
|
||||
},
|
||||
{
|
||||
name: 'TaskHooks',
|
||||
|
||||
@@ -9,7 +9,7 @@ The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (
|
||||
- `read(id, caller?): TaskRead` — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a `read` change); final kinds read the terminal output idempotently.
|
||||
- `kill(id, caller?, reason?)` — `'requested'` (live task: producer `cancel` runs first — a throw fails the kill loud and leaves the task untouched — then `stopping`) or `'already-terminal'`. Every successful kill marks the task `reported` (the killer saw the end → completion notice suppressed).
|
||||
- `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only — unless the task already settled, in which case the wait still resolves and delivers the terminal snapshot (settlement suppressed the completion notice on this waiter's behalf, so rejecting would leave the finish both unreported and un-noticed). Timing is a [`dsh-timeout`](../../util/timeout/README.md) `deadline()` scoped to the `TASK_WAIT_TIMEOUT` code, so a nested foreign deadline never misreads as a wait timeout; timeout and abort detach their settlement resolver immediately, keeping retention bounded while the task remains live.
|
||||
- `onTaskDone(listener)` — exactly once per terminal task record, with the snapshot plus its exact lifecycle owner (or `undefined`); effect-scoped, per-listener containment, silent after service disposal.
|
||||
- `onTaskDone(listener)` — exactly once per terminal task record, with the snapshot plus its exact lifecycle owner (or `undefined`); effect-scoped, contains synchronous throws and returned promise rejections without awaiting listener work, silent after service disposal.
|
||||
- `attachSurface(name)` — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.
|
||||
|
||||
Every read/kill/wait/get compares the task's owner session (`owner.session.header.id`) with the caller's and rejects a foreign one — ids are predictable (`bash-1`), so the fence, not id secrecy, is the isolation boundary.
|
||||
|
||||
@@ -350,8 +350,9 @@ export class TaskService extends Service {
|
||||
* Register a completion listener, called exactly once per terminal task
|
||||
* record with its snapshot and exact lifecycle owner (or `undefined` for an
|
||||
* unowned task). 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.
|
||||
* containment (one throwing or rejecting listener is logged, never starves
|
||||
* the rest); returned promises are observed but not awaited; never fires
|
||||
* after this service is disposed.
|
||||
* @param listener - called with each terminal snapshot and its exact owner.
|
||||
* @returns the disposer that unregisters the listener.
|
||||
*/
|
||||
@@ -440,7 +441,10 @@ export class TaskService extends Service {
|
||||
const snapshot = this.snapshot(task)
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(snapshot, task.owner)
|
||||
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)}`)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,10 @@ export interface TaskRead {
|
||||
/**
|
||||
* Completion callback registered via {@link TaskService.onTaskDone}.
|
||||
* `owner` is the exact lifecycle instance supplied at start, not a registry
|
||||
* lookup by reusable agent or session id; it is absent for unowned tasks.
|
||||
* lookup by reusable agent or session id; it is absent for unowned tasks. A
|
||||
* returned promise is observed for rejection but does not delay settlement.
|
||||
*/
|
||||
export type TaskDoneListener = (snapshot: TaskSnapshot, owner: Agent | undefined) => void
|
||||
export type TaskDoneListener = (
|
||||
snapshot: TaskSnapshot,
|
||||
owner: Agent | undefined,
|
||||
) => void | PromiseLike<void>
|
||||
|
||||
@@ -155,6 +155,23 @@ describe('TaskService reads and settlement', () => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom'))
|
||||
})
|
||||
|
||||
it('contains a rejecting onTaskDone listener without starving later listeners', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: TaskId[] = []
|
||||
ctx.tasks.onTaskDone(async () => { throw new Error('async listener boom') })
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
expect(seen).toEqual([id])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTaskDone listener rejected'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom'))
|
||||
})
|
||||
|
||||
it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
|
||||
Reference in New Issue
Block a user