fix(prompt-gate): harden internal prompt dispatch
This commit is contained in:
@@ -73,6 +73,7 @@ export * from "./record-type-guard"
|
||||
export * from "./session-directory-resolver"
|
||||
export * from "./session-route"
|
||||
export * from "./prompt-tools"
|
||||
export * from "./prompt-failure-classifier"
|
||||
export * from "./compaction-marker"
|
||||
export * from "./internal-initiator-marker"
|
||||
export * from "./plugin-command-discovery"
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should reject concurrent promptAsync retries for the same session after one dispatch is reserved", async () => {
|
||||
it("should coalesce concurrent promptAsync retries for the same session after one dispatch is reserved", async () => {
|
||||
// given two callers racing to send into one session
|
||||
let releasePrompt: (() => void) | undefined
|
||||
const promptGate = new Promise<void>((resolve) => {
|
||||
@@ -269,10 +269,10 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
// then only the reserved dispatch is sent to OpenCode
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
expect(results[0]?.status).toBe("fulfilled")
|
||||
expect(results[1]?.status).toBe("rejected")
|
||||
expect(results[1]?.status).toBe("fulfilled")
|
||||
})
|
||||
|
||||
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
|
||||
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is coalesced by the queue", async () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
@@ -290,14 +290,13 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
|
||||
// when
|
||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const second = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
|
||||
// then
|
||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("#given same-source retry observes a peer reservation #when it rejects #then the peer hold remains reserved", async () => {
|
||||
it("#given same-source retry observes a peer reservation #when it coalesces #then a different prompt remains queued behind the hold", async () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
@@ -315,9 +314,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
|
||||
// when
|
||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
await expect(
|
||||
promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const third = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
@@ -329,7 +326,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
})
|
||||
|
||||
// then
|
||||
expect(third).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
|
||||
expect(third).toEqual({ status: "queued", queuedBy: "model-suggestion-retry", position: 1 })
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -432,7 +429,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
})
|
||||
|
||||
// then
|
||||
expect(second).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
|
||||
expect(second).toEqual({ status: "queued", queuedBy: "model-suggestion-retry", position: 1 })
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -564,7 +561,7 @@ 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 () => {
|
||||
it("#given sync prompt retry just dispatched #when the same session is prompted again immediately #then the second caller is coalesced by the queue", async () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
@@ -582,10 +579,9 @@ describe("promptSyncWithModelSuggestionRetry", () => {
|
||||
|
||||
// when
|
||||
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const second = promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
|
||||
// then
|
||||
await expect(second).rejects.toThrow("prompt skipped by gate: reserved")
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "./prompt-timeout-context"
|
||||
import {
|
||||
dispatchInternalPrompt,
|
||||
isInternalPromptDispatchAccepted,
|
||||
releasePromptAsyncReservation,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
@@ -118,11 +119,12 @@ export async function promptWithModelSuggestionRetry(
|
||||
} as Parameters<typeof client.session.promptAsync>[0],
|
||||
source: "model-suggestion-retry",
|
||||
settleMs: 0,
|
||||
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
@@ -162,11 +164,12 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
source: "model-suggestion-retry:sync",
|
||||
settleMs: 0,
|
||||
checkStatus: false,
|
||||
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
@@ -220,11 +223,12 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
source: "model-suggestion-retry:sync-retry",
|
||||
settleMs: 0,
|
||||
checkStatus: false,
|
||||
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||
export const DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
|
||||
export const DEFAULT_PROMPT_QUEUE_RETRY_MS = 250
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path?: { id?: string }
|
||||
@@ -44,11 +45,16 @@ type PromptClient<TInput> = {
|
||||
}
|
||||
|
||||
export type InternalPromptDispatchMode = "async" | "sync"
|
||||
export type InternalPromptQueueBehavior = "enqueue" | "defer"
|
||||
|
||||
type InternalPromptDispatchCommonArgs<TInput> = {
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
dedupeKey?: string
|
||||
queueBehavior?: InternalPromptQueueBehavior
|
||||
queue?: boolean
|
||||
queueRetryMs?: number
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
@@ -63,6 +69,7 @@ export type InternalPromptDispatchArgs<TInput = PromptAsyncInput> = InternalProm
|
||||
|
||||
type PromptAsyncReservation = {
|
||||
source: string
|
||||
dedupeKey: string
|
||||
reservedAt: number
|
||||
token: symbol
|
||||
expiresAt?: number
|
||||
@@ -75,6 +82,7 @@ let promptGateMessagesFetchTimeoutMsForTesting: number | undefined
|
||||
|
||||
export type InternalPromptDispatchResult =
|
||||
| { status: "dispatched"; response: unknown }
|
||||
| { status: "queued"; queuedBy: string; position: number }
|
||||
| { status: "active" }
|
||||
| { status: "reserved"; reservedBy: string }
|
||||
| { status: "unavailable" }
|
||||
@@ -88,6 +96,35 @@ type PromptAsyncReservationReleaseOptions = {
|
||||
}
|
||||
|
||||
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
||||
const promptQueues = new Map<string, QueuedInternalPrompt[]>()
|
||||
const promptQueueDraining = new Set<string>()
|
||||
const promptQueueInFlight = new Map<string, QueuedInternalPrompt>()
|
||||
const promptQueueTimers = new Map<string, unknown>()
|
||||
let promptQueueSequence = 0
|
||||
|
||||
type PromptDispatchClient = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type QueuedInternalPrompt = {
|
||||
id: number
|
||||
sessionID: string
|
||||
sessionName: "promptAsync" | "prompt"
|
||||
client: PromptDispatchClient
|
||||
input: unknown
|
||||
source: string
|
||||
dedupeKey: string
|
||||
settleMs: number
|
||||
postDispatchHoldMs: number
|
||||
dispatchTimeoutMs: number
|
||||
queueRetryMs: number
|
||||
checkStatus: boolean
|
||||
checkToolState: boolean
|
||||
dispatch: (input: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function _setPromptGateMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
|
||||
promptGateMessagesFetchTimeoutMsForTesting = value
|
||||
@@ -98,15 +135,20 @@ function getPromptGateMessagesFetchTimeoutMs(): number {
|
||||
}
|
||||
|
||||
function pruneExpiredReservations(now = Date.now()): void {
|
||||
const expiredSessionIDs: string[] = []
|
||||
for (const [sessionID, reservation] of promptAsyncReservations) {
|
||||
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
expiredSessionIDs.push(sessionID)
|
||||
log("[prompt-async-gate] expired reservation released", {
|
||||
sessionID,
|
||||
source: reservation.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const sessionID of expiredSessionIDs) {
|
||||
schedulePromptQueueDrain(sessionID, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
|
||||
@@ -114,6 +156,102 @@ function getActiveReservation(sessionID: string): PromptAsyncReservation | undef
|
||||
return promptAsyncReservations.get(sessionID)
|
||||
}
|
||||
|
||||
function getPromptQueue(sessionID: string): QueuedInternalPrompt[] {
|
||||
const existing = promptQueues.get(sessionID)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const queue: QueuedInternalPrompt[] = []
|
||||
promptQueues.set(sessionID, queue)
|
||||
return queue
|
||||
}
|
||||
|
||||
function setPromptQueue(sessionID: string, queue: QueuedInternalPrompt[]): void {
|
||||
if (queue.length === 0) {
|
||||
promptQueues.delete(sessionID)
|
||||
return
|
||||
}
|
||||
promptQueues.set(sessionID, queue)
|
||||
}
|
||||
|
||||
function stringifyPromptInputForDedupe(input: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(input, (key: string, value: unknown): unknown => {
|
||||
if (key === "signal") {
|
||||
return "[AbortSignal]"
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return `[Function:${value.name}]`
|
||||
}
|
||||
return value
|
||||
})
|
||||
return serialized ?? String(input)
|
||||
} catch {
|
||||
return String(input)
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDedupeKey(source: string, input: unknown): string {
|
||||
const fingerprint = stringifyPromptInputForDedupe(input)
|
||||
return `${source}:${fingerprint.length}:${fingerprint.slice(0, 8192)}`
|
||||
}
|
||||
|
||||
function queuedResult(entry: QueuedInternalPrompt, position: number, queuedBy = entry.source): InternalPromptDispatchResult {
|
||||
return {
|
||||
status: "queued",
|
||||
queuedBy,
|
||||
position,
|
||||
}
|
||||
}
|
||||
|
||||
function clearPromptQueueTimer(sessionID: string): void {
|
||||
const timer = promptQueueTimers.get(sessionID)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
promptQueueTimers.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePromptQueueDrain(sessionID: string, delayMs: number): void {
|
||||
const queue = promptQueues.get(sessionID)
|
||||
if (!queue || queue.length === 0) {
|
||||
clearPromptQueueTimer(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
clearPromptQueueTimer(sessionID)
|
||||
const timer = setTimeout(() => {
|
||||
promptQueueTimers.delete(sessionID)
|
||||
void drainPromptQueue(sessionID).catch((error: unknown) => {
|
||||
log("[prompt-async-gate] queued prompt drain failed", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
}, Math.max(0, delayMs))
|
||||
promptQueueTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
function removePromptQueueEntry(sessionID: string, entry: QueuedInternalPrompt): void {
|
||||
const queue = promptQueues.get(sessionID)
|
||||
if (!queue) {
|
||||
return
|
||||
}
|
||||
const nextQueue = queue.filter((queued) => queued.id !== entry.id)
|
||||
setPromptQueue(sessionID, nextQueue)
|
||||
}
|
||||
|
||||
function getQueuedPromptBlocker(sessionID: string): string | undefined {
|
||||
const inFlight = promptQueueInFlight.get(sessionID)
|
||||
if (inFlight) {
|
||||
return inFlight.source
|
||||
}
|
||||
|
||||
const queue = promptQueues.get(sessionID)
|
||||
return queue?.[0]?.source
|
||||
}
|
||||
|
||||
function reservationSourceMatches(
|
||||
reservationSource: string,
|
||||
expectedSource: string | readonly string[],
|
||||
@@ -341,6 +479,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
dedupeKey: string
|
||||
settleMs: number
|
||||
postDispatchHoldMs: number
|
||||
dispatchTimeoutMs: number
|
||||
@@ -354,6 +493,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
dedupeKey,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
@@ -375,6 +515,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
source,
|
||||
dedupeKey,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
@@ -447,6 +588,117 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function drainPromptQueue(sessionID: string, awaitedEntry?: QueuedInternalPrompt): Promise<InternalPromptDispatchResult | undefined> {
|
||||
if (promptQueueDraining.has(sessionID)) {
|
||||
return awaitedEntry ? queuedResult(awaitedEntry, 1) : undefined
|
||||
}
|
||||
|
||||
promptQueueDraining.add(sessionID)
|
||||
clearPromptQueueTimer(sessionID)
|
||||
|
||||
let awaitedResult: InternalPromptDispatchResult | undefined
|
||||
try {
|
||||
while (true) {
|
||||
const queue = promptQueues.get(sessionID)
|
||||
const entry = queue?.[0]
|
||||
if (!entry) {
|
||||
break
|
||||
}
|
||||
|
||||
promptQueueInFlight.set(sessionID, entry)
|
||||
const result = await dispatchAfterSessionIdle({
|
||||
sessionName: entry.sessionName,
|
||||
client: entry.client,
|
||||
sessionID: entry.sessionID,
|
||||
input: entry.input,
|
||||
source: entry.source,
|
||||
dedupeKey: entry.dedupeKey,
|
||||
settleMs: entry.settleMs,
|
||||
postDispatchHoldMs: entry.postDispatchHoldMs,
|
||||
dispatchTimeoutMs: entry.dispatchTimeoutMs,
|
||||
checkStatus: entry.checkStatus,
|
||||
checkToolState: entry.checkToolState,
|
||||
dispatch: entry.dispatch,
|
||||
})
|
||||
if (promptQueueInFlight.get(sessionID)?.id === entry.id) {
|
||||
promptQueueInFlight.delete(sessionID)
|
||||
}
|
||||
|
||||
if (result.status === "active" || result.status === "reserved") {
|
||||
const queued = queuedResult(
|
||||
entry,
|
||||
1,
|
||||
result.status === "reserved" ? result.reservedBy : entry.source,
|
||||
)
|
||||
if (awaitedEntry?.id === entry.id) {
|
||||
awaitedResult = queued
|
||||
}
|
||||
schedulePromptQueueDrain(sessionID, entry.queueRetryMs)
|
||||
break
|
||||
}
|
||||
|
||||
removePromptQueueEntry(sessionID, entry)
|
||||
if (awaitedEntry?.id === entry.id) {
|
||||
awaitedResult = result
|
||||
}
|
||||
|
||||
const remainingQueue = promptQueues.get(sessionID)
|
||||
if (!remainingQueue || remainingQueue.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
schedulePromptQueueDrain(sessionID, entry.postDispatchHoldMs)
|
||||
break
|
||||
}
|
||||
} finally {
|
||||
promptQueueDraining.delete(sessionID)
|
||||
}
|
||||
|
||||
return awaitedResult
|
||||
}
|
||||
|
||||
async function enqueueInternalPrompt(entry: QueuedInternalPrompt): Promise<InternalPromptDispatchResult> {
|
||||
const activeReservation = getActiveReservation(entry.sessionID)
|
||||
if (activeReservation?.dedupeKey === entry.dedupeKey) {
|
||||
log("[prompt-async-gate] queued prompt coalesced with recent dispatch", {
|
||||
sessionID: entry.sessionID,
|
||||
source: entry.source,
|
||||
queuedBy: activeReservation.source,
|
||||
})
|
||||
return queuedResult(entry, 0, activeReservation.source)
|
||||
}
|
||||
|
||||
const queue = getPromptQueue(entry.sessionID)
|
||||
const existingIndex = queue.findIndex((queued) => queued.dedupeKey === entry.dedupeKey)
|
||||
if (existingIndex >= 0) {
|
||||
const existing = queue[existingIndex]
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] queued prompt coalesced with pending dispatch", {
|
||||
sessionID: entry.sessionID,
|
||||
source: entry.source,
|
||||
queuedBy: existing.source,
|
||||
position: existingIndex + 1,
|
||||
})
|
||||
return queuedResult(existing, existingIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
queue.push(entry)
|
||||
log("[prompt-async-gate] queued prompt accepted", {
|
||||
sessionID: entry.sessionID,
|
||||
source: entry.source,
|
||||
position: queue.length,
|
||||
})
|
||||
|
||||
if (queue.length > 1 || promptQueueDraining.has(entry.sessionID)) {
|
||||
schedulePromptQueueDrain(entry.sessionID, 0)
|
||||
return queuedResult(entry, queue.length)
|
||||
}
|
||||
|
||||
const result = await drainPromptQueue(entry.sessionID, entry)
|
||||
return result ?? queuedResult(entry, 1)
|
||||
}
|
||||
|
||||
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
args: InternalPromptDispatchArgs<TInput>,
|
||||
): Promise<InternalPromptDispatchResult> {
|
||||
@@ -457,6 +709,8 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
source,
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const dedupeKey = args.dedupeKey ?? createDefaultDedupeKey(source, input)
|
||||
const queueRetryMs = args.queueRetryMs ?? DEFAULT_PROMPT_QUEUE_RETRY_MS
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const sessionName = args.mode === "async" ? "promptAsync" : "prompt"
|
||||
@@ -483,12 +737,59 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
if (args.queueBehavior === "defer") {
|
||||
const activeReservation = getActiveReservation(sessionID)
|
||||
if (activeReservation) {
|
||||
return { status: "reserved", reservedBy: activeReservation.source }
|
||||
}
|
||||
|
||||
const queuedBy = getQueuedPromptBlocker(sessionID)
|
||||
if (queuedBy !== undefined || promptQueueDraining.has(sessionID)) {
|
||||
return { status: "reserved", reservedBy: queuedBy ?? source }
|
||||
}
|
||||
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName,
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
dedupeKey,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch,
|
||||
})
|
||||
}
|
||||
|
||||
if (args.queue !== false) {
|
||||
return enqueueInternalPrompt({
|
||||
id: promptQueueSequence += 1,
|
||||
sessionID,
|
||||
sessionName,
|
||||
client,
|
||||
input,
|
||||
source,
|
||||
dedupeKey,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
queueRetryMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch: dispatch as (dispatchInput: unknown) => Promise<unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName,
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
dedupeKey,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
@@ -500,9 +801,20 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
|
||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||
promptAsyncReservations.clear()
|
||||
promptQueues.clear()
|
||||
promptQueueDraining.clear()
|
||||
promptQueueInFlight.clear()
|
||||
for (const timer of promptQueueTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
promptQueueTimers.clear()
|
||||
promptGateMessagesFetchTimeoutMsForTesting = undefined
|
||||
}
|
||||
|
||||
export function isInternalPromptDispatchAccepted(result: InternalPromptDispatchResult): boolean {
|
||||
return result.status === "dispatched" || result.status === "queued"
|
||||
}
|
||||
|
||||
export function releasePromptAsyncReservation(
|
||||
sessionID: string,
|
||||
source: string,
|
||||
@@ -524,6 +836,13 @@ export function releasePromptAsyncReservation(
|
||||
}
|
||||
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
const inFlight = promptQueueInFlight.get(sessionID)
|
||||
if (inFlight?.dedupeKey === existing.dedupeKey) {
|
||||
removePromptQueueEntry(sessionID, inFlight)
|
||||
promptQueueInFlight.delete(sessionID)
|
||||
promptQueueDraining.delete(sessionID)
|
||||
}
|
||||
schedulePromptQueueDrain(sessionID, 0)
|
||||
log("[prompt-async-gate] promptAsync reservation released", {
|
||||
sessionID,
|
||||
source,
|
||||
|
||||
@@ -16,7 +16,7 @@ const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
|
||||
],
|
||||
[
|
||||
path.join(SOURCE_ROOT, "plugin", "unstable-agent-babysitter.ts"),
|
||||
"binds SDK Session.prompt/.promptAsync into a narrow facade consumed only by gate-routed unstable-agent-babysitter dispatch; performs no direct dispatch itself",
|
||||
"binds SDK Session.promptAsync into a narrow facade consumed only by gate-routed unstable-agent-babysitter dispatch; performs no direct dispatch itself",
|
||||
],
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
|
||||
@@ -287,4 +287,21 @@ describe("production prompt injection routes", () => {
|
||||
// then
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test("#given production TypeScript sources #when prompt gate callers are audited #then callers cannot bypass the central prompt queue", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
if (/queue\s*:\s*false\b/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { isAmbiguousPromptDispatchFailure } from "./prompt-failure-classifier"
|
||||
|
||||
describe("prompt failure classifier", () => {
|
||||
test("#given prompt dispatch reports a generic JSON parse error #when classifying ambiguity #then it treats the dispatch as possibly accepted", () => {
|
||||
// given
|
||||
const error = new Error("JSON Parse error: Unexpected end of JSON input")
|
||||
|
||||
// when
|
||||
const ambiguous = isAmbiguousPromptDispatchFailure(error)
|
||||
|
||||
// then
|
||||
expect(ambiguous).toBe(true)
|
||||
})
|
||||
|
||||
test("#given prompt dispatch timeout casing varies #when classifying ambiguity #then it treats the dispatch as possibly accepted", () => {
|
||||
// given
|
||||
const error = "PromptAsync Timed Out after 30000ms"
|
||||
|
||||
// when
|
||||
const ambiguous = isAmbiguousPromptDispatchFailure(error)
|
||||
|
||||
// then
|
||||
expect(ambiguous).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
export function extractPromptFailureMessage(error: unknown): string {
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const record = error as Record<string, unknown>
|
||||
if (typeof record.message === "string") return record.message
|
||||
try {
|
||||
return JSON.stringify(error)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isAmbiguousPromptDispatchFailure(error: unknown): boolean {
|
||||
const message = extractPromptFailureMessage(error).toLowerCase()
|
||||
return (
|
||||
message.includes("unexpected eof")
|
||||
|| message.includes("json parse error")
|
||||
|| message.includes("unexpected end of json input")
|
||||
|| message.includes("timed out")
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface PromptTimeoutArgs {
|
||||
|
||||
export interface PromptRetryOptions {
|
||||
timeoutMs?: number
|
||||
queueBehavior?: "enqueue" | "defer"
|
||||
}
|
||||
|
||||
export const PROMPT_TIMEOUT_MS = 120000
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("promptAsyncInDirectory", () => {
|
||||
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 () => {
|
||||
test("#given a routed prompt just dispatched #when the same session is prompted again immediately #then the route coalesces the duplicate", async () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => ({ data: "sent" }))
|
||||
const client = {
|
||||
@@ -46,7 +46,7 @@ describe("promptAsyncInDirectory", () => {
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
)
|
||||
const second = promptAsyncInDirectory(
|
||||
const second = await promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
@@ -54,7 +54,7 @@ describe("promptAsyncInDirectory", () => {
|
||||
|
||||
// then
|
||||
expect(first).toEqual({ data: "sent" })
|
||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
expect(second).toBeUndefined()
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
promptSyncWithModelSuggestionRetry,
|
||||
promptWithModelSuggestionRetry,
|
||||
} from "./model-suggestion-retry"
|
||||
import { dispatchInternalPrompt } from "./prompt-async-gate"
|
||||
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "./prompt-async-gate"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -70,10 +70,10 @@ export function promptAsyncInDirectory(
|
||||
if (result.status === "failed") {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== "dispatched") {
|
||||
if (!isInternalPromptDispatchAccepted(result)) {
|
||||
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
||||
}
|
||||
return result.response
|
||||
return result.status === "dispatched" ? result.response : undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user