fix(prompt-gate): scope reservation releases

This commit is contained in:
YeonGyu-Kim
2026-05-15 21:52:29 +09:00
parent 2eec0d96d9
commit 2bd4944bad
8 changed files with 156 additions and 8 deletions
@@ -117,7 +117,9 @@ export function createRecoveryLogic(
hasTools: !!tools, hasTools: !!tools,
recoveredPromptConfig, recoveredPromptConfig,
}) })
releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery") releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery", {
reservedBy: "compaction-context-injector",
})
return false return false
} }
@@ -197,7 +197,9 @@ export function createRalphLoopEventHandler(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props) const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
if (runtimeRetryActivitySessionID) { if (runtimeRetryActivitySessionID) {
releasePromptAsyncReservation(runtimeRetryActivitySessionID, "ralph-loop:activity") releasePromptAsyncReservation(runtimeRetryActivitySessionID, "ralph-loop:activity", {
reservedBy: HOOK_NAME,
})
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID) runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID) recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
} }
+4 -1
View File
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { RalphLoopOptions, RalphLoopState } from "./types" import type { RalphLoopOptions, RalphLoopState } from "./types"
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
import { releasePromptAsyncReservation } from "../shared/prompt-async-gate" import { releasePromptAsyncReservation } from "../shared/prompt-async-gate"
import { HOOK_NAME } from "./constants"
import { createLoopStateController } from "./loop-state-controller" import { createLoopStateController } from "./loop-state-controller"
import { createRalphLoopEventHandler } from "./ralph-loop-event-handler" import { createRalphLoopEventHandler } from "./ralph-loop-event-handler"
@@ -71,7 +72,9 @@ export function createRalphLoopHook(
startLoop: (sessionID, prompt, loopOptions): boolean => { startLoop: (sessionID, prompt, loopOptions): boolean => {
const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions) const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions)
if (startSuccess) { if (startSuccess) {
releasePromptAsyncReservation(sessionID, "ralph-loop:start-loop") releasePromptAsyncReservation(sessionID, "ralph-loop:start-loop", {
reservedBy: HOOK_NAME,
})
} }
if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") { if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") {
return startSuccess return startSuccess
@@ -90,7 +90,9 @@ export async function handleFailedVerification(
} }
try { try {
releasePromptAsyncReservation(parentSessionID, "ralph-loop:verification-failed") releasePromptAsyncReservation(parentSessionID, "ralph-loop:verification-failed", {
reservedBy: HOOK_NAME,
})
const promptResult = await injectContinuationPrompt(ctx, { const promptResult = await injectContinuationPrompt(ctx, {
sessionID: parentSessionID, sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(previewState), prompt: buildVerificationFailurePrompt(previewState),
+4 -1
View File
@@ -47,7 +47,10 @@ export function createAutoRetryHelpers(deps: HookDeps) {
} }
try { try {
await ctx.client.session.abort({ path: { id: sessionID } }) 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 }) log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID })
} catch (error) { } catch (error) {
log(`[${HOOK_NAME}] Failed to abort in-flight session request (${source})`, { log(`[${HOOK_NAME}] Failed to abort in-flight session request (${source})`, {
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { import {
promptAfterSessionIdle, promptAfterSessionIdle,
promptAsyncAfterSessionIdle, promptAsyncAfterSessionIdle,
releasePromptAsyncReservation,
releaseAllPromptAsyncReservationsForTesting, releaseAllPromptAsyncReservationsForTesting,
} from "./prompt-async-gate" } from "./prompt-async-gate"
@@ -154,6 +155,94 @@ describe("promptAsyncAfterSessionIdle", () => {
expect(promptCalls).toBe(2) 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: '<peer_message from="teammate">hello</peer_message>' }],
},
},
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 () => { test("#given two internal prompt calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => {
// given // given
let promptCalls = 0 let promptCalls = 0
+4 -1
View File
@@ -469,7 +469,10 @@ export function createEventHandler(args: {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => {
log("[event] model-fallback abort failed", { sessionID, source, 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 const launchAgent = fallbackContext?.agentName
? resolveRegisteredAgentName(fallbackContext.agentName) ? resolveRegisteredAgentName(fallbackContext.agentName)
+46 -2
View File
@@ -43,6 +43,11 @@ export type PromptAsyncGateResult =
| { status: "unavailable" } | { status: "unavailable" }
| { status: "failed"; error: unknown } | { status: "failed"; error: unknown }
type PromptAsyncReservationReleaseOptions = {
reservedBy?: string | readonly string[]
reservedByPrefix?: string | readonly string[]
}
const promptAsyncReservations = new Map<string, PromptAsyncReservation>() const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
function pruneExpiredReservations(now = Date.now()): void { function pruneExpiredReservations(now = Date.now()): void {
@@ -62,6 +67,30 @@ function getActiveReservation(sessionID: string): PromptAsyncReservation | undef
return promptAsyncReservations.get(sessionID) 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<TInput = PromptAsyncInput>(args: { export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
client: PromptAsyncClient<TInput> client: PromptAsyncClient<TInput>
sessionID: string sessionID: string
@@ -216,10 +245,24 @@ export function releaseAllPromptAsyncReservationsForTesting(): void {
promptAsyncReservations.clear() 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) const existing = promptAsyncReservations.get(sessionID)
if (!existing) { 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) promptAsyncReservations.delete(sessionID)
@@ -228,4 +271,5 @@ export function releasePromptAsyncReservation(sessionID: string, source: string)
source, source,
reservedBy: existing.source, reservedBy: existing.source,
}) })
return true
} }