From 2bd4944bad41ecff0becce2f08b961523166acbe Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 21:52:29 +0900 Subject: [PATCH] fix(prompt-gate): scope reservation releases --- .../compaction-context-injector/recovery.ts | 4 +- .../ralph-loop/ralph-loop-event-handler.ts | 4 +- src/hooks/ralph-loop/ralph-loop-hook.ts | 5 +- .../verification-failure-handler.ts | 4 +- src/hooks/runtime-fallback/auto-retry.ts | 5 +- src/hooks/shared/prompt-async-gate.test.ts | 89 +++++++++++++++++++ src/plugin/event.ts | 5 +- src/shared/prompt-async-gate.ts | 48 +++++++++- 8 files changed, 156 insertions(+), 8 deletions(-) diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index 8b74294ea..91713d7e2 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -117,7 +117,9 @@ export function createRecoveryLogic( hasTools: !!tools, recoveredPromptConfig, }) - releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery") + releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery", { + reservedBy: "compaction-context-injector", + }) return false } diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 5f12ec406..4128f1f7e 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -197,7 +197,9 @@ export function createRalphLoopEventHandler( const props = event.properties as Record | undefined const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props) if (runtimeRetryActivitySessionID) { - releasePromptAsyncReservation(runtimeRetryActivitySessionID, "ralph-loop:activity") + releasePromptAsyncReservation(runtimeRetryActivitySessionID, "ralph-loop:activity", { + reservedBy: HOOK_NAME, + }) runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID) recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID) } diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 70923be5e..a99c2ff9c 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { RalphLoopOptions, RalphLoopState } from "./types" import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" import { releasePromptAsyncReservation } from "../shared/prompt-async-gate" +import { HOOK_NAME } from "./constants" import { createLoopStateController } from "./loop-state-controller" import { createRalphLoopEventHandler } from "./ralph-loop-event-handler" @@ -71,7 +72,9 @@ export function createRalphLoopHook( startLoop: (sessionID, prompt, loopOptions): boolean => { const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions) if (startSuccess) { - releasePromptAsyncReservation(sessionID, "ralph-loop:start-loop") + releasePromptAsyncReservation(sessionID, "ralph-loop:start-loop", { + reservedBy: HOOK_NAME, + }) } if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") { return startSuccess diff --git a/src/hooks/ralph-loop/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index 52874ae98..79f336ac0 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -90,7 +90,9 @@ export async function handleFailedVerification( } try { - releasePromptAsyncReservation(parentSessionID, "ralph-loop:verification-failed") + releasePromptAsyncReservation(parentSessionID, "ralph-loop:verification-failed", { + reservedBy: HOOK_NAME, + }) const promptResult = await injectContinuationPrompt(ctx, { sessionID: parentSessionID, prompt: buildVerificationFailurePrompt(previewState), diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 8d48aa5c1..73b2105d6 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -47,7 +47,10 @@ export function createAutoRetryHelpers(deps: HookDeps) { } try { await ctx.client.session.abort({ path: { id: sessionID } }) - releasePromptAsyncReservation(sessionID, `runtime-fallback-abort:${source}`) + releasePromptAsyncReservation(sessionID, `runtime-fallback-abort:${source}`, { + reservedBy: `runtime-fallback:${source}`, + reservedByPrefix: "runtime-fallback:", + }) log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID }) } catch (error) { log(`[${HOOK_NAME}] Failed to abort in-flight session request (${source})`, { diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index a4f5e3e91..6f326bc60 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { promptAfterSessionIdle, promptAsyncAfterSessionIdle, + releasePromptAsyncReservation, releaseAllPromptAsyncReservationsForTesting, } from "./prompt-async-gate" @@ -154,6 +155,94 @@ describe("promptAsyncAfterSessionIdle", () => { expect(promptCalls).toBe(2) }) + test("#given a peer-message promptAsync hold #when an unrelated route releases the session #then the peer-message hold remains reserved", async () => { + // given + let promptCalls = 0 + const client = { + session: { + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const first = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_release_scope", + input: { + path: { id: "ses_release_scope" }, + body: { + parts: [{ type: "text", text: 'hello' }], + }, + }, + source: "team-live-delivery", + settleMs: 0, + }) + releasePromptAsyncReservation("ses_release_scope", "ralph-loop:activity") + const second = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_release_scope", + input: { + path: { id: "ses_release_scope" }, + body: { parts: [{ type: "text", text: "continue" }] }, + }, + source: "todo-continuation-enforcer", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(first.status).toBe("dispatched") + expect(second).toEqual({ status: "reserved", reservedBy: "team-live-delivery" }) + expect(promptCalls).toBe(1) + }) + + test("#given a route family promptAsync hold #when the same family aborts another source #then the reservation is released", async () => { + // given + let promptCalls = 0 + const client = { + session: { + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const first = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_release_family_scope", + input: { + path: { id: "ses_release_family_scope" }, + body: { parts: [{ type: "text", text: "continue" }] }, + }, + source: "model-fallback:message.updated", + settleMs: 0, + }) + const released = releasePromptAsyncReservation( + "ses_release_family_scope", + "model-fallback-abort:session.error", + { reservedByPrefix: "model-fallback:" }, + ) + const second = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_release_family_scope", + input: { + path: { id: "ses_release_family_scope" }, + body: { parts: [{ type: "text", text: "continue again" }] }, + }, + source: "model-fallback:session.error", + settleMs: 0, + }) + + // then + expect(first.status).toBe("dispatched") + expect(released).toBe(true) + expect(second.status).toBe("dispatched") + expect(promptCalls).toBe(2) + }) + test("#given two internal prompt calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => { // given let promptCalls = 0 diff --git a/src/plugin/event.ts b/src/plugin/event.ts index c071caf91..62a0f877c 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -469,7 +469,10 @@ export function createEventHandler(args: { await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { log("[event] model-fallback abort failed", { sessionID, source, error }); }); - releasePromptAsyncReservation(sessionID, `model-fallback-abort:${source}`); + releasePromptAsyncReservation(sessionID, `model-fallback-abort:${source}`, { + reservedBy: [`model-fallback:${source}`, `model-fallback:${source}:sync`], + reservedByPrefix: "model-fallback:", + }); const launchAgent = fallbackContext?.agentName ? resolveRegisteredAgentName(fallbackContext.agentName) diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index ca1bac8a6..7e9688e13 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -43,6 +43,11 @@ export type PromptAsyncGateResult = | { status: "unavailable" } | { status: "failed"; error: unknown } +type PromptAsyncReservationReleaseOptions = { + reservedBy?: string | readonly string[] + reservedByPrefix?: string | readonly string[] +} + const promptAsyncReservations = new Map() function pruneExpiredReservations(now = Date.now()): void { @@ -62,6 +67,30 @@ function getActiveReservation(sessionID: string): PromptAsyncReservation | undef return promptAsyncReservations.get(sessionID) } +function reservationSourceMatches( + reservationSource: string, + expectedSource: string | readonly string[], + expectedPrefix?: string | readonly string[], +): boolean { + if (typeof expectedSource === "string") { + if (reservationSource === expectedSource) { + return true + } + } else if (expectedSource.includes(reservationSource)) { + return true + } + + if (expectedPrefix === undefined) { + return false + } + + if (typeof expectedPrefix === "string") { + return reservationSource.startsWith(expectedPrefix) + } + + return expectedPrefix.some((prefix) => reservationSource.startsWith(prefix)) +} + export async function promptAsyncAfterSessionIdle(args: { client: PromptAsyncClient sessionID: string @@ -216,10 +245,24 @@ export function releaseAllPromptAsyncReservationsForTesting(): void { promptAsyncReservations.clear() } -export function releasePromptAsyncReservation(sessionID: string, source: string): void { +export function releasePromptAsyncReservation( + sessionID: string, + source: string, + options?: PromptAsyncReservationReleaseOptions, +): boolean { const existing = promptAsyncReservations.get(sessionID) if (!existing) { - return + return false + } + + const expectedSource = options?.reservedBy ?? source + if (!reservationSourceMatches(existing.source, expectedSource, options?.reservedByPrefix)) { + log("[prompt-async-gate] promptAsync reservation release skipped for different source", { + sessionID, + source, + reservedBy: existing.source, + }) + return false } promptAsyncReservations.delete(sessionID) @@ -228,4 +271,5 @@ export function releasePromptAsyncReservation(sessionID: string, source: string) source, reservedBy: existing.source, }) + return true }