fix(prompt-gate): scope reservation releases
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user