fix(prompt-gate): scope reservation releases
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,9 @@ export function createRalphLoopEventHandler(
|
||||
const props = event.properties as Record<string, unknown> | 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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})`, {
|
||||
|
||||
@@ -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: '<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 () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
@@ -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<string, PromptAsyncReservation>()
|
||||
|
||||
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<TInput = PromptAsyncInput>(args: {
|
||||
client: PromptAsyncClient<TInput>
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user