fix(background-agent): coalesce parent wake races

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-18 15:38:52 +09:00
parent c712b71d9a
commit af1ad4d04b
2 changed files with 156 additions and 1 deletions
@@ -194,6 +194,12 @@ export class ParentWakeNotifier {
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status === "reserved" && promptResult.reservedBy === "background-agent-parent-wake") {
log("[background-agent] Ignored duplicate parent wake flush already reserved by promptAsync gate:", {
sessionID,
})
return
}
if (promptResult.status !== "dispatched") {
this.requeueWake(sessionID, latestWake)
this.schedulePendingParentWakeFlush(sessionID)
@@ -451,7 +457,7 @@ export class ParentWakeNotifier {
if (createdAt === undefined) {
return false
}
return Date.now() - createdAt < this.options.userMessageInProgressWindowMs
return Date.now() - createdAt <= this.options.userMessageInProgressWindowMs
}
if (role === "assistant" || role === "tool") {
// An assistant/tool message is more recent than the last user message,
@@ -67,6 +67,155 @@ function createNotifier(args: {
}
describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
test("#given user message was created exactly at the race-window boundary #when flushing pending wake #then dispatch is still deferred", async () => {
// given
const originalDateNow = Date.now
Date.now = () => 10_000
const { notifier, promptAsyncCalls } = createNotifier({
sessionMessages: [
{
info: {
role: "user",
time: { created: 8_000 },
},
},
],
userMessageInProgressWindowMs: 2_000,
})
notifier.queuePendingParentWake(
"parent-boundary",
"task complete",
{ agent: "sisyphus" },
true,
)
try {
// when
await notifier.flushPendingParentWake("parent-boundary")
// then
expect(promptAsyncCalls).toHaveLength(0)
expect(notifier.getPendingParentWakes().has("parent-boundary")).toBe(true)
} finally {
Date.now = originalDateNow
notifier.shutdown()
releaseAllPromptAsyncReservationsForTesting()
}
})
test("#given user message is just outside the race-window boundary #when flushing pending wake #then dispatch proceeds", async () => {
// given
const originalDateNow = Date.now
Date.now = () => 10_001
const { notifier, promptAsyncCalls } = createNotifier({
sessionMessages: [
{
info: {
role: "user",
time: { created: 8_000 },
},
},
],
userMessageInProgressWindowMs: 2_000,
})
notifier.queuePendingParentWake(
"parent-boundary-open",
"task complete",
{ agent: "sisyphus" },
true,
)
try {
// when
await notifier.flushPendingParentWake("parent-boundary-open")
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(notifier.getPendingParentWakes().has("parent-boundary-open")).toBe(false)
} finally {
Date.now = originalDateNow
notifier.shutdown()
releaseAllPromptAsyncReservationsForTesting()
}
})
test("#given two flushes race for one pending wake #when both reach the prompt gate #then the skipped duplicate is not requeued", async () => {
// given
const { notifier, promptAsyncCalls } = createNotifier({
sessionMessages: [
{
info: {
role: "assistant",
finish: "stop",
time: { created: Date.now() - 10_000 },
},
},
],
})
notifier.queuePendingParentWake(
"parent-concurrent",
"task complete",
{ agent: "sisyphus" },
true,
)
// when
await Promise.all([
notifier.flushPendingParentWake("parent-concurrent"),
notifier.flushPendingParentWake("parent-concurrent"),
])
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(notifier.getPendingParentWakes().has("parent-concurrent")).toBe(false)
expect(notifier.getPendingParentWakeTimers().has("parent-concurrent")).toBe(false)
notifier.shutdown()
releaseAllPromptAsyncReservationsForTesting()
})
test("#given burst notifications share a parent session #when the pending wake flushes #then one dispatch drains the coalesced wake", async () => {
// given
const { notifier, promptAsyncCalls } = createNotifier({
sessionMessages: [
{
info: {
role: "assistant",
finish: "stop",
time: { created: Date.now() - 10_000 },
},
},
],
})
notifier.queuePendingParentWake(
"parent-burst",
"task one complete",
{ agent: "sisyphus" },
false,
)
notifier.queuePendingParentWake(
"parent-burst",
"task two complete",
{ agent: "sisyphus" },
true,
)
// when
await Promise.all([
notifier.flushPendingParentWake("parent-burst"),
notifier.flushPendingParentWake("parent-burst"),
])
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
expect(notifier.getPendingParentWakes().has("parent-burst")).toBe(false)
expect(notifier.getPendingParentWakeTimers().has("parent-burst")).toBe(false)
notifier.shutdown()
releaseAllPromptAsyncReservationsForTesting()
})
test("#given latest message is a user message just added #when flushing pending wake #then dispatch is deferred (no promptAsync)", async () => {
// given
const { notifier, promptAsyncCalls } = createNotifier({