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:
@@ -266,6 +266,31 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
expect(results[1]?.status).toBe("rejected")
|
||||
})
|
||||
|
||||
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "session-post-dispatch-hold" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const second = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
|
||||
// then
|
||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should throw error from promptAsync directly on model-not-found error", async () => {
|
||||
// given a client that fails with model-not-found error
|
||||
const promptMock = mock().mockRejectedValueOnce({
|
||||
@@ -436,6 +461,31 @@ describe("promptSyncWithModelSuggestionRetry", () => {
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("#given sync prompt retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "session-sync-post-dispatch-hold" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const second = promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
|
||||
// then
|
||||
await expect(second).rejects.toThrow("prompt skipped by gate: reserved")
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should abort and throw timeout error when sync prompt hangs", async () => {
|
||||
// given a client where sync prompt never resolves unless aborted
|
||||
let receivedSignal: AbortSignal | undefined
|
||||
|
||||
@@ -105,7 +105,6 @@ export async function promptWithModelSuggestionRetry(
|
||||
} as Parameters<typeof client.session.promptAsync>[0],
|
||||
source: "model-suggestion-retry",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
@@ -145,7 +144,6 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
} as Parameters<typeof client.session.prompt>[0],
|
||||
source: "model-suggestion-retry:sync",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
checkStatus: false,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
@@ -198,7 +196,6 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
} as Parameters<typeof client.session.prompt>[0],
|
||||
source: "model-suggestion-retry:sync-retry",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
checkStatus: false,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const PROMPT_GATE_FILE = path.join(SOURCE_ROOT, "shared", "prompt-async-gate.ts")
|
||||
|
||||
async function listSourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const nestedFiles = await Promise.all(entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return listSourceFiles(entryPath)
|
||||
}
|
||||
if (
|
||||
entry.isFile()
|
||||
&& entry.name.endsWith(".ts")
|
||||
&& !entry.name.endsWith(".test.ts")
|
||||
&& !entry.name.endsWith(".d.ts")
|
||||
) {
|
||||
return [entryPath]
|
||||
}
|
||||
return []
|
||||
}))
|
||||
|
||||
return nestedFiles.flat()
|
||||
}
|
||||
|
||||
function relativeSourcePath(filePath: string): string {
|
||||
return path.relative(SOURCE_ROOT, filePath)
|
||||
}
|
||||
|
||||
function uncommentedLines(contents: string): string[] {
|
||||
return contents
|
||||
.split("\n")
|
||||
.map((line) => line.trimStart())
|
||||
.filter((line) => !line.startsWith("//") && !line.startsWith("*"))
|
||||
}
|
||||
|
||||
describe("production prompt injection routes", () => {
|
||||
test("#given production TypeScript sources #when prompt routes are audited #then only the shared gate may call raw OpenCode prompt APIs", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
if (filePath === PROMPT_GATE_FILE) {
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
if (/\bsession\.promptAsync\s*\(/.test(contents) || /\bsession\.prompt\s*\(/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test("#given production TypeScript sources #when prompt gate callers are audited #then callers cannot disable the post-dispatch reservation hold", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
if (/postDispatchHoldMs\s*:\s*0\b/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
|
||||
import { promptAsyncInDirectory } from "./session-route"
|
||||
|
||||
describe("promptAsyncInDirectory", () => {
|
||||
test("#given no session id is present #when routing a promptAsync request #then the helper rejects instead of using an ungated raw prompt", async () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => ({ data: "sent" }))
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
}
|
||||
|
||||
// when, then
|
||||
await expect(
|
||||
promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
),
|
||||
).rejects.toThrow("session id is required for routed promptAsync")
|
||||
expect(promptAsync).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("#given a routed prompt just dispatched #when the same session is prompted again immediately #then the route keeps the session reserved", async () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => ({ data: "sent" }))
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "ses_route_hold" },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
)
|
||||
const second = promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(first).toEqual({ data: "sent" })
|
||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
|
||||
})
|
||||
})
|
||||
@@ -56,7 +56,7 @@ export function promptAsyncInDirectory(
|
||||
const routedArgs = routeSessionPrompt(args, directory)
|
||||
const sessionID = routedArgs.path?.id
|
||||
if (!sessionID) {
|
||||
return client.session.promptAsync(routedArgs)
|
||||
return Promise.reject(new Error("session id is required for routed promptAsync"))
|
||||
}
|
||||
|
||||
return promptAsyncAfterSessionIdle({
|
||||
@@ -65,7 +65,6 @@ export function promptAsyncInDirectory(
|
||||
input: routedArgs,
|
||||
source: "session-route",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
}).then((result) => {
|
||||
if (result.status === "failed") {
|
||||
throw result.error
|
||||
|
||||
Reference in New Issue
Block a user