fix(tool-jobs): count wait claims per call

Two concurrent job_output waits on one job shared a single ledger entry, so the first wait's timeout withdrew the claim the second wait still held and the settlement injected a completion notice the model had already received through that wait. Claims are counted per call now; a withdraw releases only its own.
This commit is contained in:
Yichen Jiang committed 2026-09-04 12:25:15 +08:00
1 parent 0712a4cc68
commit 9f0600d11b
2 files changed
+32 -7

No files matched your search

+17 -7
View File
@@ -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<JobId>()
// 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<JobId, number>()
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
@@ -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()