fix(prompt-gate): pin duplicate prompt dispatches

Keep prompt reservations briefly after successful dispatch so rapid idle/message/error transitions cannot inject the same follow-up twice.

Route all production session prompt calls through the shared gate, restore skipped background resume state, release holds after abort/recovery paths, and preserve Ralph/ULW loop state when a dispatch is deferred.

Add regression coverage for session routing, static prompt route auditing, team-mode live messaging, model suggestion retries, call-omo-agent reuse, background parent wakes, runtime fallback, compaction recovery, Atlas, and Ralph/ULW loops.
This commit is contained in:
YeonGyu-Kim
2026-05-15 13:19:10 +09:00
parent 05189700fb
commit c2aa180e7e
26 changed files with 893 additions and 48 deletions
+34 -6
View File
@@ -33,6 +33,7 @@ type PromptAsyncReservation = {
source: string
reservedAt: number
token: symbol
expiresAt?: number
}
export type PromptAsyncGateResult =
@@ -44,6 +45,23 @@ export type PromptAsyncGateResult =
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
function pruneExpiredReservations(now = Date.now()): void {
for (const [sessionID, reservation] of promptAsyncReservations) {
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
promptAsyncReservations.delete(sessionID)
log("[prompt-async-gate] expired reservation released", {
sessionID,
source: reservation.source,
})
}
}
}
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
pruneExpiredReservations()
return promptAsyncReservations.get(sessionID)
}
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
client: PromptAsyncClient<TInput>
sessionID: string
@@ -67,7 +85,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
return { status: "unavailable" }
}
const existing = promptAsyncReservations.get(sessionID)
const existing = getActiveReservation(sessionID)
if (existing) {
log("[prompt-async-gate] promptAsync skipped because session is reserved", {
sessionID,
@@ -84,6 +102,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let holdReservationAfterDispatch = false
try {
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
@@ -99,7 +118,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
log("[prompt-async-gate] promptAsync dispatching", { sessionID, source })
const response = await client.session.promptAsync(input)
if (postDispatchHoldMs > 0) {
await settleAfterSessionIdle(postDispatchHoldMs)
holdReservationAfterDispatch = true
}
log("[prompt-async-gate] promptAsync dispatched", { sessionID, source })
return { status: "dispatched", response }
@@ -109,7 +128,11 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
promptAsyncReservations.delete(sessionID)
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
}
@@ -137,7 +160,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
return { status: "unavailable" }
}
const existing = promptAsyncReservations.get(sessionID)
const existing = getActiveReservation(sessionID)
if (existing) {
log("[prompt-async-gate] prompt skipped because session is reserved", {
sessionID,
@@ -154,6 +177,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let holdReservationAfterDispatch = false
try {
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
@@ -169,7 +193,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
log("[prompt-async-gate] prompt dispatching", { sessionID, source })
const response = await client.session.prompt(input)
if (postDispatchHoldMs > 0) {
await settleAfterSessionIdle(postDispatchHoldMs)
holdReservationAfterDispatch = true
}
log("[prompt-async-gate] prompt dispatched", { sessionID, source })
return { status: "dispatched", response }
@@ -179,7 +203,11 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
promptAsyncReservations.delete(sessionID)
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
}