diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index d4d3d68220..b7fd9f274a 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -258,12 +258,22 @@ export function apply(ctx: Context, config: Config): void { // `job_kill` it requested. A wait claims when it starts, not when it // returns — settlement releases waiters before it announces, but their // continuations run after the event — and withdraws the claim if the job - // outlives the wait. The settlement event drops the entry, so the ledger - // holds only live jobs the model already touched. - const delivered = new Set() + // outlives the wait. Claims are counted per call, so a timed-out wait + // withdraws only its own claim while a concurrent wait on the same job + // keeps the settlement covered. The settlement event drops the entry, so + // the ledger holds only live jobs the model already touched. + const claims = new Map() const claim = (id: JobId): (() => void) => { - delivered.add(id) - return () => { delivered.delete(id) } + claims.set(id, (claims.get(id) ?? 0) + 1) + return () => { + // A withdraw runs only for a wait that returned or rejected while the + // job was live, so its own claim is still counted here; the fallback + // only discharges the Map's optional read. + /* v8 ignore next -- settlement hands every waiter the terminal view before dropping the entry; such waits never withdraw. */ + const remaining = (claims.get(id) ?? 1) - 1 + if (remaining > 0) claims.set(id, remaining) + else claims.delete(id) + } } // A busy owner is injected: the notice waits in its next-step inbox, which @@ -277,11 +287,11 @@ export function apply(ctx: Context, config: Config): void { // this listener owns delivery, not the choice of whom to deliver to. ctx.jobs.events.subscribe({ owners: 'scope' }, (event) => { if (event.type === 'removed') { - delivered.delete(event.job.id) + claims.delete(event.job.id) return } if (event.type !== 'settled') return - const claimed = delivered.delete(event.job.id) + const claimed = claims.delete(event.job.id) if (claimed || event.cause === 'teardown' || event.job.owner === undefined) return // The destination is the agent registered for the owner session now. An // owned job needed the agent registry to start, so the registry is only diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index f100dfdf96..ef1814d57e 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -945,6 +945,21 @@ describe('completion notices', () => { expect(inject).not.toHaveBeenCalled() }) + it('a timed-out wait withdraws only its own claim; a concurrent wait keeps the settlement covered', async () => { + const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 1000 }) + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + const patient = call(ctx, 'job_output', { job_id: 'bash-1', wait: true, timeout_ms: 1000 }, owner) + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1', wait: true, timeout_ms: 10 }, owner))).toBe('(no new output)\n[status: running]') + p.settle({ status: 'completed' }) + expect(text(await patient)).toBe('(no new output)\n[status: completed]') + await tick() + expect(inject).not.toHaveBeenCalled() + }) + it('withdraws the wait claim when the wait times out, so the later settlement still notifies', async () => { const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 }) const inject = vi.fn()