Merge pull request #4034 from code-yeongyu/fix/promptasync-duplicate-output
fix(prompt-async): gate duplicate internal prompts
This commit is contained in:
@@ -173,6 +173,7 @@ Schema autocomplete: `"$schema": "https://raw.githubusercontent.com/code-yeongyu
|
||||
- **Per-session MCP isolation:** Tier-3 MCP clients keyed by `${sessionID}:${skillName}:${serverName}` so the same skill in two sessions does not share state.
|
||||
- **Two fallback systems:** `model-fallback` (proactive, chat.params) vs `runtime-fallback` (reactive, session.error). They operate independently — no direct integration.
|
||||
- **OpenClaw bidirectional:** Outbound dispatchers fire on session events; inbound daemon polls Discord/Telegram and `send-keys` replies into the tracked tmux pane.
|
||||
- **Internal message injection is dangerous:** OpenCode의 stupid한 설계로 플러그인이 `session.prompt` / `session.promptAsync` 같은 메인 세션 메시지 API를 통해 메인 시스템을 망가뜨릴 수 있다. Any route that injects a message into an existing session must go through `src/shared/prompt-async-gate.ts` or an equivalent per-session reservation/status gate, must check active session state before dispatch, and must have a regression test proving duplicate concurrent injections collapse to one dispatch. This applies to background completion wakes, fallback retries, team mailbox live delivery, recovery continuations, CLI run resumes, Claude Code hook injections, and sync/background subagent prompts.
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
|
||||
+22
-9
@@ -13,6 +13,7 @@ import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||
import { suppressRunInput } from "./stdin-suppression"
|
||||
import { createTimestampedStdoutController } from "./timestamp-output"
|
||||
import { createCliPostHog, getPostHogDistinctId } from "../../shared/posthog"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
|
||||
export { resolveRunAgent }
|
||||
|
||||
@@ -109,18 +110,30 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
() => {},
|
||||
)
|
||||
|
||||
await client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: resolvedAgent,
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
tools: {
|
||||
question: false,
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID,
|
||||
source: "cli-run",
|
||||
settleMs: 0,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: resolvedAgent,
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
tools: {
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: message }],
|
||||
},
|
||||
parts: [{ type: "text", text: message }],
|
||||
query: { directory },
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
throw new Error(`Session ${sessionID} is not idle; promptAsync skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||
|
||||
abortController.abort()
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
|
||||
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
|
||||
|
||||
@@ -27,6 +28,11 @@ mock.restore()
|
||||
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
type PendingParentWakeForTest = {
|
||||
promptContext: Record<string, unknown>
|
||||
notifications: string[]
|
||||
shouldReply: boolean
|
||||
}
|
||||
|
||||
class MockBackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask> = new Map()
|
||||
@@ -235,6 +241,14 @@ function getPendingNotifications(manager: BackgroundManager): Map<string, string
|
||||
return (cast<{ pendingNotifications: Map<string, string[]> }>(manager)).pendingNotifications
|
||||
}
|
||||
|
||||
function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return (cast<{ pendingParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).pendingParentWakes
|
||||
}
|
||||
|
||||
function getDispatchedParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return (cast<{ dispatchedParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).dispatchedParentWakes
|
||||
}
|
||||
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return (cast<{ completionTimers: Map<string, ReturnType<typeof setTimeout>> }>(manager)).completionTimers
|
||||
}
|
||||
@@ -2208,6 +2222,123 @@ describe("BackgroundManager.resume concurrency key", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.resume promptAsync gate state", () => {
|
||||
test("restores completed task state when resume prompt is skipped because the session is active", async () => {
|
||||
//#given
|
||||
let promptCallCount = 0
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "session-active-resume": { type: "busy" } } }),
|
||||
promptAsync: async () => {
|
||||
promptCallCount += 1
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-active-resume-skip",
|
||||
sessionId: "session-active-resume",
|
||||
parentSessionId: "parent-session-original",
|
||||
parentMessageId: "msg-original",
|
||||
description: "completed task",
|
||||
prompt: "original prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(Date.now() - 1000),
|
||||
completedAt: new Date(),
|
||||
error: "previous terminal note",
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
const originalCompletedAt = task.completedAt
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-active-resume",
|
||||
prompt: "continue",
|
||||
parentSessionId: "parent-session-new",
|
||||
parentMessageId: "msg-new",
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(promptCallCount).toBe(0)
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.completedAt).toBe(originalCompletedAt)
|
||||
expect(task.error).toBe("previous terminal note")
|
||||
expect(task.parentSessionId).toBe("parent-session-original")
|
||||
expect(task.parentMessageId).toBe("msg-original")
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
expect(getConcurrencyManager(manager).getCount("explore")).toBe(0)
|
||||
expect(getPendingByParent(manager).get("parent-session-new")).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("restores completed task state when resume prompt is skipped by an existing reservation", async () => {
|
||||
//#given
|
||||
let promptCallCount = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCallCount += 1
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "session-reserved-resume",
|
||||
source: "test-existing-reservation",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 1000,
|
||||
input: {
|
||||
path: { id: "session-reserved-resume" },
|
||||
body: { parts: [] },
|
||||
},
|
||||
})
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-reserved-resume-skip",
|
||||
sessionId: "session-reserved-resume",
|
||||
parentSessionId: "parent-session-original",
|
||||
parentMessageId: "msg-original",
|
||||
description: "completed task",
|
||||
prompt: "original prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(Date.now() - 1000),
|
||||
completedAt: new Date(),
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-reserved-resume",
|
||||
prompt: "continue",
|
||||
parentSessionId: "parent-session-new",
|
||||
parentMessageId: "msg-new",
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(promptCallCount).toBe(1)
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.parentSessionId).toBe("parent-session-original")
|
||||
expect(task.parentMessageId).toBe("msg-original")
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
expect(getConcurrencyManager(manager).getCount("explore")).toBe(0)
|
||||
expect(getPendingByParent(manager).get("parent-session-new")).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.resume model persistence", () => {
|
||||
let manager: BackgroundManager
|
||||
let promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }>
|
||||
@@ -4938,6 +5069,105 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("terminates task when agent-not-found arrives as async session.error after promptAsync accept", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
mockVerifySessionExists(manager, true)
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
const concurrencyKey = "missing-agent"
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-session-error-agent-not-found",
|
||||
sessionId: "ses-agent-not-found",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "msg-agent-not-found",
|
||||
description: "task with missing agent",
|
||||
agent: "missing-agent",
|
||||
status: "running",
|
||||
concurrencyKey,
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: task.sessionId,
|
||||
error: {
|
||||
name: "AgentNotFoundError",
|
||||
message: "Agent not found: missing-agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("interrupt")
|
||||
expect(task.error).toBe("Agent \"missing-agent\" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.")
|
||||
expect(task.completedAt).toBeInstanceOf(Date)
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
|
||||
expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined()
|
||||
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("requeues dispatched parent wake when the wake prompt fails through session.error", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "parent-session-wake": { type: "idle" } } }),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const managerInternals = cast<{
|
||||
queuePendingParentWake: (
|
||||
sessionID: string,
|
||||
notification: string,
|
||||
promptContext: Record<string, unknown>,
|
||||
shouldReply: boolean,
|
||||
delayMs?: number,
|
||||
) => void
|
||||
flushPendingParentWake: (sessionID: string) => Promise<void>
|
||||
}>(manager)
|
||||
managerInternals.queuePendingParentWake(
|
||||
"parent-session-wake",
|
||||
"<system-reminder>done</system-reminder>",
|
||||
{ agent: "sisyphus" },
|
||||
true,
|
||||
0,
|
||||
)
|
||||
|
||||
//#when
|
||||
await managerInternals.flushPendingParentWake("parent-session-wake")
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "parent-session-wake",
|
||||
error: { name: "UnknownError", message: "wake prompt failed" },
|
||||
},
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(getDispatchedParentWakes(manager).has("parent-session-wake")).toBe(false)
|
||||
expect(getPendingParentWakes(manager).get("parent-session-wake")?.notifications).toEqual([
|
||||
"<system-reminder>done</system-reminder>",
|
||||
])
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("terminates task on session.error when session is gone", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
createInternalAgentTextPart,
|
||||
messagesInDirectory,
|
||||
promptAsyncInDirectory,
|
||||
promptWithRetryInDirectory,
|
||||
} from "../../shared"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
@@ -67,6 +66,7 @@ import {
|
||||
isSessionActive as isOpenCodeSessionActive,
|
||||
settleAfterSessionIdle,
|
||||
} from "../../hooks/shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
|
||||
import {
|
||||
findNearestMessageExcludingCompaction,
|
||||
resolvePromptContextFromSessionMessages,
|
||||
@@ -110,6 +110,21 @@ type PendingParentWake = {
|
||||
shouldReply: boolean
|
||||
}
|
||||
|
||||
type ResumeTaskSnapshot = {
|
||||
status: BackgroundTask["status"]
|
||||
completedAt?: Date
|
||||
error?: string
|
||||
startedAt?: Date
|
||||
progress?: BackgroundTask["progress"]
|
||||
parentSessionId: string
|
||||
parentMessageId: string
|
||||
parentModel?: BackgroundTask["parentModel"]
|
||||
parentAgent?: string
|
||||
parentTools?: Record<string, boolean>
|
||||
concurrencyKey?: string
|
||||
concurrencyGroup?: string
|
||||
}
|
||||
|
||||
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
|
||||
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
|
||||
|
||||
@@ -196,6 +211,7 @@ export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => P
|
||||
|
||||
const MAX_TASK_REMOVAL_RESCHEDULES = 6
|
||||
const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100
|
||||
const PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS = 5_000
|
||||
|
||||
export interface BackgroundManagerConfig {
|
||||
pluginContext: PluginInput
|
||||
@@ -236,6 +252,8 @@ export class BackgroundManager {
|
||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private observedOutputSessions: Set<string> = new Set()
|
||||
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
|
||||
private rootDescendantCounts: Map<string, number>
|
||||
@@ -422,6 +440,60 @@ export class BackgroundManager {
|
||||
this.tasksByParentSession.set(parentSessionID, taskIDs)
|
||||
}
|
||||
|
||||
private captureResumeTaskSnapshot(task: BackgroundTask): ResumeTaskSnapshot {
|
||||
return {
|
||||
status: task.status,
|
||||
completedAt: task.completedAt,
|
||||
error: task.error,
|
||||
startedAt: task.startedAt,
|
||||
progress: task.progress,
|
||||
parentSessionId: task.parentSessionId,
|
||||
parentMessageId: task.parentMessageId,
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
parentTools: task.parentTools,
|
||||
concurrencyKey: task.concurrencyKey,
|
||||
concurrencyGroup: task.concurrencyGroup,
|
||||
}
|
||||
}
|
||||
|
||||
private restoreTaskAfterSkippedResume(
|
||||
task: BackgroundTask,
|
||||
snapshot: ResumeTaskSnapshot,
|
||||
skippedStatus: Exclude<PromptAsyncGateResult["status"], "dispatched" | "failed">,
|
||||
): void {
|
||||
log("[background-agent] Restoring task after skipped resume prompt:", {
|
||||
taskId: task.id,
|
||||
sessionID: task.sessionId,
|
||||
skippedStatus,
|
||||
})
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
}
|
||||
|
||||
task.status = snapshot.status
|
||||
task.completedAt = snapshot.completedAt
|
||||
task.error = snapshot.error
|
||||
task.startedAt = snapshot.startedAt
|
||||
task.progress = snapshot.progress
|
||||
task.parentMessageId = snapshot.parentMessageId
|
||||
task.parentModel = snapshot.parentModel
|
||||
task.parentAgent = snapshot.parentAgent
|
||||
task.parentTools = snapshot.parentTools
|
||||
task.concurrencyKey = snapshot.concurrencyKey
|
||||
task.concurrencyGroup = snapshot.concurrencyGroup
|
||||
this.updateTaskParent(task, snapshot.parentSessionId)
|
||||
|
||||
removeTaskToastTracking(task.id)
|
||||
if (task.status !== "running" && task.status !== "pending") {
|
||||
this.scheduleTaskRemoval(task.id)
|
||||
}
|
||||
this.updateBackgroundTaskMarker(task.parentSessionId)
|
||||
}
|
||||
|
||||
private removeTaskFromParentIndex(taskID: string, parentSessionID: string | undefined): void {
|
||||
if (!parentSessionID) {
|
||||
return
|
||||
@@ -1083,6 +1155,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const resumeSnapshot = this.captureResumeTaskSnapshot(existingTask)
|
||||
const completionTimer = this.completionTimers.get(existingTask.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
@@ -1161,27 +1234,46 @@ The fallback retry session is now created and can be inspected directly.
|
||||
applySessionPromptParams(existingTask.sessionId!, existingTask.model)
|
||||
}
|
||||
|
||||
promptAsyncInDirectory(this.client, {
|
||||
path: { id: existingTask.sessionId },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(existingTask.agent, {
|
||||
includeTeamToolDenylist: existingTask.teamRunId === undefined,
|
||||
}),
|
||||
}
|
||||
setSessionTools(existingTask.sessionId!, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
promptAsyncAfterSessionIdle({
|
||||
client: this.client,
|
||||
sessionID: existingTask.sessionId,
|
||||
source: "background-agent-resume",
|
||||
settleMs: 0,
|
||||
input: {
|
||||
path: { id: existingTask.sessionId },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(existingTask.agent, {
|
||||
includeTeamToolDenylist: existingTask.teamRunId === undefined,
|
||||
}),
|
||||
}
|
||||
setSessionTools(existingTask.sessionId!, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
},
|
||||
}, this.directory).catch(async (error) => {
|
||||
}).then((promptResult) => {
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log("[background-agent] resume prompt skipped by promptAsync gate:", {
|
||||
taskId: existingTask.id,
|
||||
sessionID: existingTask.sessionId,
|
||||
status: promptResult.status,
|
||||
})
|
||||
this.restoreTaskAfterSkippedResume(existingTask, resumeSnapshot, promptResult.status)
|
||||
}
|
||||
}).catch(async (error) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
const errorInfo = {
|
||||
name: extractErrorName(error),
|
||||
@@ -1257,6 +1349,60 @@ The fallback retry session is now created and can be inspected directly.
|
||||
this.observedOutputSessions.add(sessionID)
|
||||
}
|
||||
|
||||
private cloneParentWake(wake: PendingParentWake): PendingParentWake {
|
||||
return {
|
||||
promptContext: {
|
||||
...wake.promptContext,
|
||||
...(wake.promptContext.model ? { model: { ...wake.promptContext.model } } : {}),
|
||||
...(wake.promptContext.tools ? { tools: { ...wake.promptContext.tools } } : {}),
|
||||
},
|
||||
notifications: [...wake.notifications],
|
||||
shouldReply: wake.shouldReply,
|
||||
}
|
||||
}
|
||||
|
||||
private clearDispatchedParentWake(sessionID: string): void {
|
||||
const timer = this.dispatchedParentWakeTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
}
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}
|
||||
|
||||
private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void {
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
this.dispatchedParentWakes.set(sessionID, this.cloneParentWake(wake))
|
||||
const timer = setTimeout(() => {
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}, PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS)
|
||||
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
private requeueDispatchedParentWake(sessionID: string, reason: string): boolean {
|
||||
const wake = this.dispatchedParentWakes.get(sessionID)
|
||||
if (!wake) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...wake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || wake.shouldReply
|
||||
pendingWake.promptContext = wake.promptContext
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, this.cloneParentWake(wake))
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Requeued dispatched parent wake after prompt failure:", {
|
||||
sessionID,
|
||||
reason,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private clearSessionOutputObserved(sessionID: string): void {
|
||||
this.observedOutputSessions.delete(sessionID)
|
||||
}
|
||||
@@ -1288,6 +1434,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = (info as Record<string, unknown>)["role"]
|
||||
if (!sessionID) return
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
|
||||
if (role === "tool") {
|
||||
this.markSessionOutputObserved(sessionID)
|
||||
@@ -1320,6 +1467,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const partInfo = resolveMessagePartInfo(props)
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID) return
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
|
||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||
if (!resolved?.isCurrent) return
|
||||
@@ -1450,7 +1598,10 @@ The fallback retry session is now created and can be inspected directly.
|
||||
if (!sessionID) return
|
||||
|
||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||
if (!resolved?.isCurrent) return
|
||||
if (!resolved?.isCurrent) {
|
||||
this.requeueDispatchedParentWake(sessionID, "session.error")
|
||||
return
|
||||
}
|
||||
|
||||
const { task } = resolved
|
||||
if (task.status !== "running") return
|
||||
@@ -1562,6 +1713,67 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
}
|
||||
|
||||
private async interruptTaskFromAsyncPromptFailure(
|
||||
task: BackgroundTask,
|
||||
errorMessage: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (task.currentAttemptID) {
|
||||
finalizeAttempt(task, task.currentAttemptID, "interrupt", errorMessage)
|
||||
} else {
|
||||
task.status = "interrupt"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
}
|
||||
|
||||
if (task.rootSessionId) {
|
||||
this.unregisterRootDescendant(task.rootSessionId)
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionId, {
|
||||
id: task.id,
|
||||
sessionID: task.sessionId,
|
||||
agent: task.agent,
|
||||
description: task.description,
|
||||
status: "interrupt",
|
||||
category: task.category,
|
||||
startedAt: task.startedAt,
|
||||
completedAt: task.completedAt,
|
||||
})
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
const completionTimer = this.completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
this.completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
removeTaskToastTracking(task.id)
|
||||
this.scheduleTaskRemoval(task.id)
|
||||
|
||||
if (task.sessionId) {
|
||||
SessionCategoryRegistry.remove(task.sessionId)
|
||||
await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`)
|
||||
}
|
||||
|
||||
this.updateBackgroundTaskMarker(task.parentSessionId)
|
||||
this.markForNotification(task)
|
||||
this.enqueueNotificationForParent(task.parentSessionId, () => this.notifyParentSession(task)).catch(err => {
|
||||
log("[background-agent] Failed to notify on async prompt failure:", { taskId: task.id, error: err })
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSessionErrorEvent(args: {
|
||||
task: BackgroundTask
|
||||
errorInfo: { name?: string; message?: string }
|
||||
@@ -1577,13 +1789,16 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
}
|
||||
|
||||
// Agent-not-found errors are handled by the prompt catch block with agent fallback.
|
||||
// Do not also trigger model fallback retry — that would race with the agent retry.
|
||||
if (isAgentNotFoundError({ message: errorInfo.message } as Error)) {
|
||||
log("[background-agent] Skipping session.error fallback for agent-not-found (handled by prompt catch)", {
|
||||
if (isAgentNotFoundError({ message: errorInfo.message ?? "" })) {
|
||||
log("[background-agent] Handling async agent-not-found session.error:", {
|
||||
taskId: task.id,
|
||||
errorMessage: errorInfo.message?.slice(0, 100),
|
||||
})
|
||||
await this.interruptTaskFromAsyncPromptFailure(
|
||||
task,
|
||||
`Agent "${task.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`,
|
||||
"agent-not-found session.error",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2328,15 +2543,43 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
const notificationContent = latestWake.notifications.join("\n\n")
|
||||
|
||||
try {
|
||||
await promptAsyncInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: !latestWake.shouldReply,
|
||||
...latestWake.promptContext,
|
||||
parts: [createInternalAgentTextPart(notificationContent)],
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: this.client,
|
||||
sessionID,
|
||||
source: "background-agent-parent-wake",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 250,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: !latestWake.shouldReply,
|
||||
...latestWake.promptContext,
|
||||
parts: [createInternalAgentTextPart(notificationContent)],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
},
|
||||
}, this.directory)
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...latestWake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
|
||||
pendingWake.promptContext = latestWake.promptContext
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, latestWake)
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||
this.trackDispatchedParentWake(sessionID, latestWake)
|
||||
} catch (error) {
|
||||
this.queuePendingNotification(sessionID, notificationContent)
|
||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||
@@ -2693,6 +2936,11 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
this.pendingParentWakeTimers.clear()
|
||||
|
||||
for (const timer of this.dispatchedParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.dispatchedParentWakeTimers.clear()
|
||||
|
||||
for (const sessionID of trackedSessionIDs) {
|
||||
subagentSessions.delete(sessionID)
|
||||
SessionCategoryRegistry.remove(sessionID)
|
||||
@@ -2705,6 +2953,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.pendingParentWakes.clear()
|
||||
this.dispatchedParentWakes.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.rootDescendantCounts.clear()
|
||||
this.queuesByKey.clear()
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
|
||||
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||
|
||||
type PromptAsyncCall = {
|
||||
@@ -29,6 +30,7 @@ let fakeTimers: FakeTimers | undefined
|
||||
afterEach(() => {
|
||||
managerUnderTest?.shutdown()
|
||||
fakeTimers?.restore()
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
managerUnderTest = undefined
|
||||
fakeTimers = undefined
|
||||
})
|
||||
@@ -163,8 +165,18 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back
|
||||
return notifyParentSession.call(manager, task)
|
||||
}
|
||||
|
||||
function waitForDeferredWake(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 180))
|
||||
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
function waitForDeferredWake(promptAsyncCalls: PromptAsyncCall[]): Promise<void> {
|
||||
return waitUntil(() => promptAsyncCalls.length > 0, 600)
|
||||
}
|
||||
|
||||
function waitForDeferredWakeRetry(): Promise<void> {
|
||||
@@ -341,7 +353,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
// when
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
|
||||
await waitForDeferredWake()
|
||||
await waitForDeferredWake(promptAsyncCalls)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
@@ -377,7 +389,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
// when
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
|
||||
await waitForDeferredWake()
|
||||
await waitForDeferredWake(promptAsyncCalls)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
@@ -424,7 +436,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
// when
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
|
||||
await waitForDeferredWake()
|
||||
await waitForDeferredWake(promptAsyncCalls)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
@@ -477,7 +489,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
|
||||
await waitForDeferredWake()
|
||||
await waitForDeferredWake(promptAsyncCalls)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createRuntimeState, saveRuntimeState } from "../team-state-store/store"
|
||||
import { clearTeamSessionRegistry, registerTeamSession } from "../team-session-registry"
|
||||
import type { Message } from "../types"
|
||||
import { MessageSchema } from "../types"
|
||||
import { createTeamIdleWakeHint } from "../../../hooks/team-session-events/team-idle-wake-hint"
|
||||
import { createTeamSendMessageTool } from "./messaging"
|
||||
|
||||
type PromptAsyncCall = {
|
||||
@@ -282,6 +283,98 @@ describe("createTeamSendMessageTool", () => {
|
||||
expect(calls[0]?.directory).toBe(resolveBaseDir(fixture.config))
|
||||
})
|
||||
|
||||
test("#given recipient OpenCode session is busy #when team_send_message attempts live delivery #then it leaves the message unread without starting another reply", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { [fixture.memberTwoSessionId]: { type: "busy" } } }),
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
||||
|
||||
// when
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "ping while busy",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
|
||||
// then
|
||||
expect(promptCalls).toBe(0)
|
||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0]?.body).toBe("ping while busy")
|
||||
})
|
||||
|
||||
test("#given rapid live deliveries to one recipient #when the first prompt just dispatched #then the next message stays unread instead of starting another reply", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
const { client, calls } = createRecordingClient()
|
||||
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
||||
|
||||
// when
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "first ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "second ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
|
||||
// then
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0]?.body).toBe("second ping")
|
||||
})
|
||||
|
||||
test("#given live delivery left a rapid message unread #when recipient idle wake fires immediately #then the wake hint does not start a second reply", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
const { client, calls } = createRecordingClient()
|
||||
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
||||
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "first ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "second ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
|
||||
const wakeHint = createTeamIdleWakeHint({
|
||||
directory: resolveBaseDir(fixture.config),
|
||||
client,
|
||||
}, fixture.config, { idleSettleMs: 0 })
|
||||
|
||||
// when
|
||||
await wakeHint({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: fixture.memberTwoSessionId },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0]?.body).toBe("second ping")
|
||||
})
|
||||
|
||||
test("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
reserveMessageForDelivery,
|
||||
} from "../team-mailbox/reservation"
|
||||
import { BroadcastNotPermittedError, sendMessage } from "../team-mailbox/send"
|
||||
import { promptAsyncAfterSessionIdle } from "../../../hooks/shared/prompt-async-gate"
|
||||
|
||||
import type { Message } from "../types"
|
||||
import { MessageSchema } from "../types"
|
||||
@@ -33,6 +34,7 @@ export type LiveDeliveryClient = {
|
||||
}
|
||||
query?: { directory: string }
|
||||
}): Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,11 +182,31 @@ async function deliverLive(
|
||||
applyMemberSessionRouting(recipientSessionId, recipientMember)
|
||||
|
||||
try {
|
||||
await client.session.promptAsync({
|
||||
path: { id: recipientSessionId },
|
||||
body: buildMemberPromptBody(recipientMember, envelope),
|
||||
query: { directory: recipientMember.worktreePath ?? directory },
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: recipientSessionId,
|
||||
source: "team-live-delivery",
|
||||
input: {
|
||||
path: { id: recipientSessionId },
|
||||
body: buildMemberPromptBody(recipientMember, envelope),
|
||||
query: { directory: recipientMember.worktreePath ?? directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log("[team-mailbox] live delivery skipped by promptAsync gate, falling back to inbox injection", {
|
||||
status: promptResult.status,
|
||||
teamRunId,
|
||||
recipient: recipientName,
|
||||
recipientSessionId,
|
||||
messageId: message.messageId,
|
||||
})
|
||||
await releaseReservationSafely(reservation, {
|
||||
teamRunId,
|
||||
recipient: recipientName,
|
||||
messageId: message.messageId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
await commitDeliveryReservation(reservation)
|
||||
log("[team-mailbox] live delivery committed", {
|
||||
teamRunId,
|
||||
|
||||
+22
-17
@@ -17,7 +17,7 @@ import {
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} from "../../features/hook-message-injector"
|
||||
import { isSessionActive } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
export async function runAggressiveTruncationStrategy(params: {
|
||||
sessionID: string
|
||||
@@ -74,13 +74,6 @@ export async function runAggressiveTruncationStrategy(params: {
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (await isSessionActive(params.client, params.sessionID)) {
|
||||
log("[auto-compact] skipped delayed auto prompt because session became active", {
|
||||
sessionID: params.sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sdkMessage = await findNearestMessageWithFieldsFromSDK(params.client, params.sessionID)
|
||||
const previousMessage = sdkMessage ?? (() => {
|
||||
const messageDir = getMessageDir(params.sessionID)
|
||||
@@ -95,17 +88,29 @@ export async function runAggressiveTruncationStrategy(params: {
|
||||
const launchVariant = previousMessage?.model?.variant
|
||||
const inheritedTools = resolveInheritedPromptTools(params.sessionID, previousMessage?.tools)
|
||||
|
||||
await params.client.session.promptAsync({
|
||||
path: { id: params.sessionID },
|
||||
body: {
|
||||
auto: true,
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: params.client,
|
||||
sessionID: params.sessionID,
|
||||
source: "auto-compact",
|
||||
settleMs: 0,
|
||||
input: {
|
||||
path: { id: params.sessionID },
|
||||
body: {
|
||||
auto: true,
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
} as never,
|
||||
query: { directory: params.directory },
|
||||
} as never,
|
||||
query: { directory: params.directory },
|
||||
})
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log("[auto-compact] delayed auto prompt skipped by promptAsync gate", {
|
||||
sessionID: params.sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log("[auto-compact] delayed auto prompt failed", {
|
||||
sessionID: params.sessionID,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { isSessionActive } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
|
||||
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
|
||||
@@ -32,6 +32,7 @@ export async function injectBoulderContinuation(input: {
|
||||
preferredTaskTitle?: string
|
||||
backgroundManager?: BackgroundTaskStatusProvider
|
||||
sessionState: SessionState
|
||||
idleSettleMs?: number
|
||||
}): Promise<BoulderContinuationResult> {
|
||||
const {
|
||||
ctx,
|
||||
@@ -45,6 +46,7 @@ export async function injectBoulderContinuation(input: {
|
||||
preferredTaskTitle,
|
||||
backgroundManager,
|
||||
sessionState,
|
||||
idleSettleMs,
|
||||
} = input
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
@@ -78,11 +80,6 @@ export async function injectBoulderContinuation(input: {
|
||||
}
|
||||
|
||||
try {
|
||||
if (await isSessionActive(ctx.client, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: session is active`, { sessionID })
|
||||
return "skipped_active_session"
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
|
||||
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
|
||||
@@ -93,7 +90,12 @@ export async function injectBoulderContinuation(input: {
|
||||
: undefined
|
||||
const launchVariant = promptContext.model?.variant
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: idleSettleMs,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: continuationAgent,
|
||||
@@ -103,7 +105,18 @@ export async function injectBoulderContinuation(input: {
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log(`[${HOOK_NAME}] Boulder continuation skipped by promptAsync gate`, {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return "skipped_active_session"
|
||||
}
|
||||
|
||||
sessionState.promptFailureCount = 0
|
||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createInternalAgentContinuationTextPart } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
@@ -52,6 +53,7 @@ async function injectContinuation(input: {
|
||||
progress: { total: number; completed: number }
|
||||
agent?: string
|
||||
worktreePath?: string
|
||||
idleSettleMs?: number
|
||||
}): Promise<void> {
|
||||
const remaining = input.progress.total - input.progress.completed
|
||||
if (input.sessionState.isInjectingContinuation) {
|
||||
@@ -110,6 +112,7 @@ async function injectContinuation(input: {
|
||||
preferredTaskTitle: preferredTaskSession?.task_title,
|
||||
backgroundManager: input.options?.backgroundManager,
|
||||
sessionState: input.sessionState,
|
||||
idleSettleMs: input.idleSettleMs,
|
||||
})
|
||||
|
||||
if (result === "injected") {
|
||||
@@ -288,14 +291,27 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: atlasAgent,
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: options?.idleSettleMs,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: atlasAgent,
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
sessionState.boulderCompletionNudgedAt = {
|
||||
...(sessionState.boulderCompletionNudgedAt ?? {}),
|
||||
[work.work_id]: Date.now(),
|
||||
@@ -398,6 +414,7 @@ export async function handleAtlasSessionIdle(input: {
|
||||
progress,
|
||||
agent: boulderState.agent,
|
||||
worktreePath: boulderState.worktree_path,
|
||||
idleSettleMs: options?.idleSettleMs ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1702,7 +1702,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
// then - stale idle is consumed, not converted into another scheduled continuation
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
expect(scheduledDelays).toHaveLength(0)
|
||||
expect(scheduledDelays.filter((delay) => delay >= 5_000)).toHaveLength(0)
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-ca
|
||||
import type { PluginConfig } from "../types"
|
||||
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
||||
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
||||
import { promptAfterSessionIdle } from "../../../shared/prompt-async-gate"
|
||||
import {
|
||||
clearAllSessionHookState,
|
||||
clearSessionHookState,
|
||||
@@ -108,17 +109,23 @@ export function createSessionEventHandler(
|
||||
})
|
||||
} else if (stopResult.block && stopResult.injectPrompt) {
|
||||
log("Stop hook returned block with inject_prompt", { sessionID })
|
||||
ctx.client.session
|
||||
.prompt({
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "claude-code-stop-hook:inject-prompt",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [createInternalAgentTextPart(stopResult.injectPrompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch((err: unknown) =>
|
||||
log("Failed to inject prompt from Stop hook", { error: String(err) }),
|
||||
)
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
log("Failed to inject prompt from Stop hook", { error: String(promptResult.error) })
|
||||
} else if (promptResult.status !== "dispatched") {
|
||||
log("Skipped prompt injection from Stop hook", { sessionID, status: promptResult.status })
|
||||
}
|
||||
} else if (stopResult.block) {
|
||||
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
|
||||
import type { CompactionContextClient } from "./types"
|
||||
import type { TailMonitorState } from "./tail-monitor"
|
||||
import { promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../shared/prompt-async-gate"
|
||||
|
||||
export function createRecoveryLogic(
|
||||
ctx: CompactionContextClient | undefined,
|
||||
@@ -81,17 +82,30 @@ export function createRecoveryLogic(
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: launchAgent ?? expectedPromptConfig.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(AGENT_RECOVERY_PROMPT)],
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "compaction-context-injector",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: launchAgent ?? expectedPromptConfig.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(AGENT_RECOVERY_PROMPT)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log(`[compaction-context-injector] Recovery skipped by promptAsync gate`, {
|
||||
sessionID,
|
||||
reason,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
|
||||
@@ -103,6 +117,7 @@ export function createRecoveryLogic(
|
||||
hasTools: !!tools,
|
||||
recoveredPromptConfig,
|
||||
})
|
||||
releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export type CompactionContextClient = {
|
||||
}
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
directory: string
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
} from "../../shared"
|
||||
import { normalizeAgentForPromptKey } from "../../shared/agent-display-names"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
type MessageInfo = {
|
||||
agent?: string
|
||||
@@ -21,6 +22,7 @@ type MessageInfo = {
|
||||
|
||||
export type ContinuationPromptResult =
|
||||
| { status: "dispatched" }
|
||||
| { status: "deferred"; reason: "active" | "reserved" }
|
||||
| { status: "rejected"; error: Error }
|
||||
|
||||
function extractPromptAsyncError(response: unknown): unknown | undefined {
|
||||
@@ -66,6 +68,7 @@ export async function injectContinuationPrompt(
|
||||
directory: string
|
||||
apiTimeoutMs: number
|
||||
inheritFromSessionID?: string
|
||||
idleSettleMs?: number
|
||||
},
|
||||
): Promise<ContinuationPromptResult> {
|
||||
let agent: string | undefined
|
||||
@@ -119,17 +122,36 @@ export async function injectContinuationPrompt(
|
||||
|
||||
let response: unknown
|
||||
try {
|
||||
response = await ctx.client.session.promptAsync({
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
...(cleanAgent !== undefined ? { agent: cleanAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(options.prompt)],
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID: options.sessionID,
|
||||
source: "ralph-loop",
|
||||
settleMs: options.idleSettleMs,
|
||||
input: {
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
...(cleanAgent !== undefined ? { agent: cleanAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(options.prompt)],
|
||||
},
|
||||
query: { directory: options.directory },
|
||||
},
|
||||
query: { directory: options.directory },
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status === "active" || promptResult.status === "reserved") {
|
||||
return { status: "deferred", reason: promptResult.status }
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
return {
|
||||
status: "rejected",
|
||||
error: createPromptAsyncError(`promptAsync skipped: ${promptResult.status}`, promptResult),
|
||||
}
|
||||
}
|
||||
response = promptResult.response
|
||||
} catch (error) {
|
||||
const promptError = error instanceof Error
|
||||
? error
|
||||
|
||||
@@ -871,6 +871,24 @@ describe("ralph-loop", () => {
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("#given duplicate real idle fires before assistant activity #then loop state is preserved without another prompt", async () => {
|
||||
// given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
|
||||
|
||||
// when - duplicate idle events arrive without any intervening activity
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - the second dispatch is deferred, not treated as loop failure
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should handle multiple iterations correctly", async () => {
|
||||
// given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
@@ -880,6 +898,9 @@ describe("ralph-loop", () => {
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "message.part.updated", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
@@ -1127,6 +1148,9 @@ describe("ralph-loop", () => {
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "message.part.updated", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
@@ -1328,6 +1352,7 @@ Original task: Build something`
|
||||
// when - delayed start snapshot resolves after the loop has already advanced
|
||||
resolveInitialMessages?.({ data: mockSessionMessages })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } })
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
|
||||
// then - the late snapshot must not hide the DONE message from verification gating
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createIterationSession, selectSessionInTui } from "./session-reset-stra
|
||||
type ContinuationOptions = {
|
||||
directory: string
|
||||
apiTimeoutMs: number
|
||||
idleSettleMs: number
|
||||
previousSessionID: string
|
||||
loopState: {
|
||||
setSessionID: (sessionID: string) => RalphLoopState | null
|
||||
@@ -17,6 +18,7 @@ type ContinuationOptions = {
|
||||
|
||||
export type ContinuationResult =
|
||||
| { status: "dispatched"; sessionID: string }
|
||||
| { status: "dispatch_deferred"; reason: "active" | "reserved" }
|
||||
| { status: "session_creation_rejected" }
|
||||
| { status: "dispatch_rejected"; error: unknown }
|
||||
|
||||
@@ -45,7 +47,11 @@ export async function continueIteration(
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
idleSettleMs: options.idleSettleMs,
|
||||
})
|
||||
if (promptResult.status === "deferred") {
|
||||
return { status: "dispatch_deferred", reason: promptResult.reason }
|
||||
}
|
||||
if (promptResult.status === "rejected") {
|
||||
return { status: "dispatch_rejected", error: promptResult.error }
|
||||
}
|
||||
@@ -73,7 +79,11 @@ export async function continueIteration(
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
idleSettleMs: options.idleSettleMs,
|
||||
})
|
||||
if (promptResult.status === "deferred") {
|
||||
return { status: "dispatch_deferred", reason: promptResult.reason }
|
||||
}
|
||||
if (promptResult.status === "rejected") {
|
||||
return { status: "dispatch_rejected", error: promptResult.error }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { isSessionActive } from "../shared/session-idle-settle"
|
||||
import { releasePromptAsyncReservation } from "../shared/prompt-async-gate"
|
||||
import type { IterationCommitExpectation, RalphLoopOptions, RalphLoopState } from "./types"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { handleDetectedCompletion } from "./completion-handler"
|
||||
@@ -196,6 +197,7 @@ 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")
|
||||
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
|
||||
recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
|
||||
}
|
||||
@@ -358,6 +360,7 @@ export function createRalphLoopEventHandler(
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
idleSettleMs: options.idleSettleMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
|
||||
@@ -395,6 +398,10 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.status === "dispatch_deferred") {
|
||||
log(`[${HOOK_NAME}] Dispatch deferred`, { sessionID, reason: result.reason })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Dispatch failed`, { sessionID, status: result.status })
|
||||
options.loopState.clear()
|
||||
@@ -523,6 +530,7 @@ export function createRalphLoopEventHandler(
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
idleSettleMs: options.idleSettleMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
|
||||
@@ -561,6 +569,10 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.status === "dispatch_deferred") {
|
||||
log(`[${HOOK_NAME}] Dispatch deferred after runtime error`, { sessionID, reason: result.reason })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Dispatch failed after runtime error`, { sessionID, status: result.status })
|
||||
options.loopState.clear()
|
||||
|
||||
@@ -1,6 +1,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 { createLoopStateController } from "./loop-state-controller"
|
||||
import { createRalphLoopEventHandler } from "./ralph-loop-event-handler"
|
||||
|
||||
@@ -69,6 +70,9 @@ export function createRalphLoopHook(
|
||||
event,
|
||||
startLoop: (sessionID, prompt, loopOptions): boolean => {
|
||||
const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions)
|
||||
if (startSuccess) {
|
||||
releasePromptAsyncReservation(sessionID, "ralph-loop:start-loop")
|
||||
}
|
||||
if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") {
|
||||
return startSuccess
|
||||
}
|
||||
|
||||
@@ -176,10 +176,11 @@ describe("ulw-loop verification", () => {
|
||||
`${JSON.stringify({ type: "assistant", timestamp: new Date().toISOString(), content: "done <promise>DONE</promise>" })}\n`,
|
||||
)
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
const stateAfterDone = hook.getState()
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
const stateAfterDone = hook.getState()
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } })
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
|
||||
expect(stateAfterDone?.verification_pending).toBe(true)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
@@ -208,10 +209,11 @@ describe("ulw-loop verification", () => {
|
||||
writeFileSync(
|
||||
oracleTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "still checking" } })}\n`,
|
||||
)
|
||||
const stateBeforeWait = hook.getState()
|
||||
)
|
||||
const stateBeforeWait = hook.getState()
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } })
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
|
||||
expect(stateBeforeWait?.verification_session_id).toBe("ses-oracle")
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { releasePromptAsyncReservation } from "../shared/prompt-async-gate"
|
||||
import { buildVerificationFailurePrompt } from "./continuation-prompt-builder"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||
@@ -80,30 +81,29 @@ export async function handleFailedVerification(
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.verification_session_id) {
|
||||
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
|
||||
const previewState: RalphLoopState = {
|
||||
...state,
|
||||
verification_pending: undefined,
|
||||
verification_session_id: undefined,
|
||||
message_count_at_start: messageCountAtStart,
|
||||
iteration: state.iteration + 1,
|
||||
}
|
||||
|
||||
const clearedState = loopState.clearVerificationState(
|
||||
parentSessionID,
|
||||
messageCountAtStart,
|
||||
)
|
||||
if (!clearedState) {
|
||||
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
|
||||
parentSessionID,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
|
||||
|
||||
try {
|
||||
releasePromptAsyncReservation(parentSessionID, "ralph-loop:verification-failed")
|
||||
const promptResult = await injectContinuationPrompt(ctx, {
|
||||
sessionID: parentSessionID,
|
||||
prompt: buildVerificationFailurePrompt(previewState),
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
})
|
||||
if (promptResult.status === "deferred") {
|
||||
log(`[${HOOK_NAME}] Deferred verification failure prompt`, {
|
||||
parentSessionID,
|
||||
reason: promptResult.reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (promptResult.status === "rejected") {
|
||||
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
|
||||
parentSessionID,
|
||||
@@ -133,6 +133,21 @@ export async function handleFailedVerification(
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.verification_session_id) {
|
||||
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
|
||||
}
|
||||
|
||||
const clearedState = loopState.clearVerificationState(
|
||||
parentSessionID,
|
||||
messageCountAtStart,
|
||||
)
|
||||
if (!clearedState) {
|
||||
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
|
||||
parentSessionID,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const committed = loopState.incrementIteration()
|
||||
if (!committed) {
|
||||
log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID })
|
||||
|
||||
@@ -10,6 +10,10 @@ import { buildRetryModelPayload } from "./retry-model-payload"
|
||||
import { getLastUserRetryParts } from "./last-user-retry-parts"
|
||||
import { extractSessionMessages } from "./session-messages"
|
||||
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
promptAsyncAfterSessionIdle,
|
||||
releasePromptAsyncReservation,
|
||||
} from "../shared/prompt-async-gate"
|
||||
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
@@ -33,6 +37,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
const abortSessionRequest = async (sessionID: string, source: string): Promise<void> => {
|
||||
try {
|
||||
await ctx.client.session.abort({ path: { id: sessionID } })
|
||||
releasePromptAsyncReservation(sessionID, `runtime-fallback-abort:${source}`)
|
||||
log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID })
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to abort in-flight session request (${source})`, {
|
||||
@@ -137,15 +142,31 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
sessionAwaitingFallbackResult.add(sessionID)
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...retryModelPayload,
|
||||
parts: retryParts,
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: `runtime-fallback:${source}`,
|
||||
settleMs: 0,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...retryModelPayload,
|
||||
parts: retryParts,
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
retryDispatched = true
|
||||
} else {
|
||||
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("runtime-fallback", () => {
|
||||
messages?: (args: unknown) => Promise<unknown>
|
||||
promptAsync?: (args: unknown) => Promise<unknown>
|
||||
abort?: (args: unknown) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}) {
|
||||
return unsafeTestValue({
|
||||
@@ -57,6 +58,7 @@ describe("runtime-fallback", () => {
|
||||
messages: overrides?.session?.messages ?? (async () => ({ data: [] })),
|
||||
promptAsync: overrides?.session?.promptAsync ?? (async () => ({})),
|
||||
abort: overrides?.session?.abort ?? (async () => ({})),
|
||||
...(overrides?.session?.status ? { status: overrides.session.status } : {}),
|
||||
},
|
||||
},
|
||||
directory: "/test/dir",
|
||||
@@ -2471,6 +2473,66 @@ describe("runtime-fallback", () => {
|
||||
expect(callBody?.agent).toBe("prometheus")
|
||||
expect(callBody?.model).toEqual({ providerID: "github-copilot", modelID: "claude-opus-4.7" })
|
||||
})
|
||||
|
||||
test("should not dispatch a second fallback prompt while the accepted retry session is still active", async () => {
|
||||
const sessionID = "test-runtime-fallback-active-gate"
|
||||
let sessionStatus = "idle"
|
||||
const promptCalls: Array<Record<string, unknown>> = []
|
||||
const hook = createRuntimeFallbackHook(
|
||||
createMockPluginInput({
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { role: "user" },
|
||||
parts: [{ type: "text", text: "retry this" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
promptAsync: async (args: unknown) => {
|
||||
promptCalls.push(args as Record<string, unknown>)
|
||||
sessionStatus = "busy"
|
||||
return {}
|
||||
},
|
||||
status: async () => ({ data: { [sessionID]: { type: sessionStatus } } }),
|
||||
},
|
||||
}),
|
||||
{
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: createMockPluginConfigWithCategoryFallback([
|
||||
"github-copilot/claude-opus-4.7",
|
||||
"openai/gpt-5.4",
|
||||
]),
|
||||
},
|
||||
)
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-7" } },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 503, message: "Service unavailable" } },
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
model: "github-copilot/claude-opus-4.7",
|
||||
error: { statusCode: 503, message: "Service unavailable" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cooldown mechanism", () => {
|
||||
|
||||
@@ -66,14 +66,14 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
||||
}
|
||||
|
||||
if (sessionID && role === "assistant" && error) {
|
||||
sessionAwaitingFallbackResult.delete(sessionID)
|
||||
const wasAwaitingFallbackResult = sessionAwaitingFallbackResult.delete(sessionID)
|
||||
if (sessionRetryInFlight.has(sessionID) && !retrySignal) {
|
||||
log(`[${HOOK_NAME}] message.updated fallback skipped (retry in flight)`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (retrySignal && sessionRetryInFlight.has(sessionID) && timeoutEnabled) {
|
||||
log(`[${HOOK_NAME}] Overriding in-flight retry due to provider auto-retry signal`, {
|
||||
if (retrySignal && timeoutEnabled && (sessionRetryInFlight.has(sessionID) || wasAwaitingFallbackResult)) {
|
||||
log(`[${HOOK_NAME}] Overriding active retry due to provider auto-retry signal`, {
|
||||
sessionID,
|
||||
model,
|
||||
})
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { MessageData, ResumeConfig } from "./types"
|
||||
import { readParts } from "./storage"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
type ClientWithPromptAsync = {
|
||||
session: {
|
||||
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +121,14 @@ export async function recoverToolResultMissing(
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.promptAsync(promptInput)
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID,
|
||||
source: "session-recovery-tool-result-missing",
|
||||
input: promptInput,
|
||||
})
|
||||
|
||||
return true
|
||||
return promptResult.status === "dispatched"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readParts } from "./storage"
|
||||
import type { MessageData } from "./types"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -100,8 +101,21 @@ export async function recoverUnavailableTool(
|
||||
body: { parts: toolResultParts },
|
||||
}
|
||||
const promptAsync = client.session.promptAsync as (...args: never[]) => unknown
|
||||
await Reflect.apply(promptAsync, client.session, [promptInput])
|
||||
return true
|
||||
const promptClient = {
|
||||
session: {
|
||||
status: client.session.status,
|
||||
promptAsync: (input: PromptWithToolResultInput) => (
|
||||
Reflect.apply(promptAsync, client.session, [input]) as Promise<unknown>
|
||||
),
|
||||
},
|
||||
}
|
||||
const promptResult = await promptAsyncAfterSessionIdle<PromptWithToolResultInput>({
|
||||
client: promptClient,
|
||||
sessionID,
|
||||
source: "session-recovery-unavailable-tool",
|
||||
input: promptInput,
|
||||
})
|
||||
return promptResult.status === "dispatched"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
||||
|
||||
@@ -32,17 +33,22 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
|
||||
: undefined
|
||||
const launchVariant = config.model?.variant
|
||||
|
||||
await client.session.promptAsync({
|
||||
path: { id: config.sessionID },
|
||||
body: {
|
||||
parts: [createInternalAgentContinuationTextPart(RECOVERY_RESUME_TEXT)],
|
||||
agent: config.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: config.sessionID,
|
||||
source: "session-recovery",
|
||||
input: {
|
||||
path: { id: config.sessionID },
|
||||
body: {
|
||||
parts: [createInternalAgentContinuationTextPart(RECOVERY_RESUME_TEXT)],
|
||||
agent: config.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
return true
|
||||
return promptResult.status === "dispatched"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
promptAfterSessionIdle,
|
||||
promptAsyncAfterSessionIdle,
|
||||
releaseAllPromptAsyncReservationsForTesting,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
describe("promptAsyncAfterSessionIdle", () => {
|
||||
afterEach(() => {
|
||||
// then
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
})
|
||||
|
||||
test("#given two internal promptAsync calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
let releasePrompt: (() => void) | undefined
|
||||
const promptGate = new Promise<void>((resolve) => {
|
||||
releasePrompt = resolve
|
||||
})
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { ses_race: { type: "idle" } } }),
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
await promptGate
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_race",
|
||||
input: { path: { id: "ses_race" }, body: { parts: [] } },
|
||||
source: "test:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_race",
|
||||
input: { path: { id: "ses_race" }, body: { parts: [] } },
|
||||
source: "test:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
releasePrompt?.()
|
||||
const firstResult = await first
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
expect(second.status).toBe("reserved")
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("#given settle is disabled and status is unavailable #when a second promptAsync starts after the first dispatch resolves #then the default dispatch hold keeps the session reserved", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_hold_after_dispatch",
|
||||
input: { path: { id: "ses_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:hold:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_hold_after_dispatch",
|
||||
input: { path: { id: "ses_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:hold:second",
|
||||
settleMs: 0,
|
||||
})
|
||||
const firstResult = await first
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
expect(second.status).toBe("reserved")
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("#given session.status reports busy #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_busy",
|
||||
input: { path: { id: "ses_busy" }, body: { parts: [] } },
|
||||
source: "test:busy",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.status).toBe("active")
|
||||
expect(promptCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("#given dispatch hold has expired #when the same session prompts again #then the next promptAsync is accepted", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
source: "test:expired:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 1,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
source: "test:expired:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("dispatched")
|
||||
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
|
||||
let releasePrompt: (() => void) | undefined
|
||||
const promptGate = new Promise<void>((resolve) => {
|
||||
releasePrompt = resolve
|
||||
})
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { ses_prompt_race: { type: "idle" } } }),
|
||||
prompt: async () => {
|
||||
promptCalls += 1
|
||||
await promptGate
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_race",
|
||||
input: { path: { id: "ses_prompt_race" }, body: { parts: [] } },
|
||||
source: "test:prompt:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const second = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_race",
|
||||
input: { path: { id: "ses_prompt_race" }, body: { parts: [] } },
|
||||
source: "test:prompt:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
releasePrompt?.()
|
||||
const firstResult = await first
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
expect(second.status).toBe("reserved")
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("#given settle is disabled and status is unavailable #when a second prompt starts after the first dispatch resolves #then the default dispatch hold keeps the session reserved", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
input: { path: { id: "ses_prompt_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:prompt-hold:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const second = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
input: { path: { id: "ses_prompt_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:prompt-hold:second",
|
||||
settleMs: 0,
|
||||
})
|
||||
const firstResult = await first
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
expect(second.status).toBe("reserved")
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../shared/prompt-async-gate"
|
||||
@@ -1,61 +1 @@
|
||||
export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150
|
||||
|
||||
export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise<void> {
|
||||
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
|
||||
}
|
||||
|
||||
type SessionStatusClient = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIVE_SESSION_STATUSES = new Set(["busy", "retry", "running"])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getSessionStatusPayload(response: unknown): Record<string, unknown> {
|
||||
if (isRecord(response) && isRecord(response.data)) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
if (isRecord(response)) {
|
||||
return response
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export function isActiveSessionStatusType(statusType: string): boolean {
|
||||
return ACTIVE_SESSION_STATUSES.has(statusType)
|
||||
}
|
||||
|
||||
export async function isSessionActive(client: SessionStatusClient, sessionID: string): Promise<boolean> {
|
||||
if (typeof client.session?.status !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await client.session.status()
|
||||
const status = getSessionStatusPayload(statusResult)[sessionID]
|
||||
if (!isRecord(status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const statusType = status.type
|
||||
return typeof statusType === "string" && isActiveSessionStatusType(statusType)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldPromptAfterSessionIdle(
|
||||
client: SessionStatusClient,
|
||||
sessionID: string,
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
): Promise<boolean> {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
return !(await isSessionActive(client, sessionID))
|
||||
}
|
||||
export * from "../../shared/session-idle-settle"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../features/team-mode/member-session-routing"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path: { id: string }
|
||||
@@ -100,23 +100,29 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
||||
}
|
||||
|
||||
applyMemberSessionRouting(sessionID, memberEntry)
|
||||
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
|
||||
log("team idle wake hint skipped because session is active", {
|
||||
event: "team-mode-idle-wake-hint-active-session",
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "team-idle-wake-hint",
|
||||
settleMs: options?.idleSettleMs,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log("team idle wake hint skipped by promptAsync gate", {
|
||||
event: "team-mode-idle-wake-hint-gated",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
memberName: memberEntry.name,
|
||||
sessionID,
|
||||
unreadCount: unreadMessages.length,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
log("team idle wake hint sent", {
|
||||
event: "team-mode-idle-wake-hint",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getAgentConfigKey,
|
||||
normalizeAgentForPromptKey,
|
||||
} from "../../shared/agent-display-names"
|
||||
import { isSessionActive } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
import {
|
||||
CONTINUATION_PROMPT,
|
||||
@@ -166,11 +166,6 @@ ${todoList}`
|
||||
return
|
||||
}
|
||||
|
||||
if (await isSessionActive(ctx.client, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: session is active before prompt`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = true
|
||||
}
|
||||
@@ -190,17 +185,33 @@ ${todoList}`
|
||||
: undefined
|
||||
const launchVariant = model?.variant
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: launchAgent ?? promptAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: 0,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: launchAgent ?? promptAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log(`[${HOOK_NAME}] Injection skipped by promptAsync gate`, { sessionID, status: promptResult.status })
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||
if (injectionState) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isUnstableTask,
|
||||
THINKING_SUMMARY_MAX_CHARS,
|
||||
} from "./task-message-analyzer"
|
||||
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
|
||||
const HOOK_NAME = "unstable-agent-babysitter"
|
||||
const DEFAULT_TIMEOUT_MS = 120000
|
||||
@@ -216,22 +216,31 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
? { providerID: model.providerID, modelID: model.modelID }
|
||||
: undefined
|
||||
const launchVariant = model?.variant
|
||||
if (!(await shouldPromptAfterSessionIdle(ctx.client, mainSessionID, options.idleSettleMs))) {
|
||||
log(`[${HOOK_NAME}] Reminder skipped because main session is active`, { taskId: task.id, sessionID: mainSessionID })
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID: mainSessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: options.idleSettleMs,
|
||||
input: {
|
||||
path: { id: mainSessionID },
|
||||
body: {
|
||||
...(agent ? { agent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentTextPart(reminder)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status !== "dispatched") {
|
||||
log(`[${HOOK_NAME}] Reminder skipped by promptAsync gate`, {
|
||||
taskId: task.id,
|
||||
sessionID: mainSessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: mainSessionID },
|
||||
body: {
|
||||
...(agent ? { agent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentTextPart(reminder)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
reminderCooldowns.set(task.id, now)
|
||||
log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID })
|
||||
} catch (error) {
|
||||
|
||||
+40
-12
@@ -42,6 +42,7 @@ import { createTeamIdleWakeHint } from "../hooks/team-session-events/team-idle-w
|
||||
import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler";
|
||||
import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler";
|
||||
import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler";
|
||||
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../hooks/shared/prompt-async-gate";
|
||||
|
||||
import type { CreatedHooks } from "../create-hooks";
|
||||
import type { Managers } from "../create-managers";
|
||||
@@ -348,6 +349,7 @@ export function createEventHandler(args: {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: pluginContext.client.session.promptAsync,
|
||||
status: pluginContext.client.session.status,
|
||||
},
|
||||
},
|
||||
}, teamModeConfig)
|
||||
@@ -467,6 +469,7 @@ 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}`);
|
||||
|
||||
const launchAgent = fallbackContext?.agentName
|
||||
? resolveRegisteredAgentName(fallbackContext.agentName)
|
||||
@@ -495,19 +498,36 @@ export function createEventHandler(args: {
|
||||
};
|
||||
|
||||
if (typeof pluginContext.client.session.promptAsync === "function") {
|
||||
await pluginContext.client.session.promptAsync(promptBody).then(() => {
|
||||
dispatched = true;
|
||||
}).catch((error) => {
|
||||
log("[event] model-fallback promptAsync failed", { sessionID, source, error });
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: pluginContext.client,
|
||||
sessionID,
|
||||
source: `model-fallback:${source}`,
|
||||
input: promptBody,
|
||||
});
|
||||
if (promptResult.status === "dispatched") {
|
||||
dispatched = true;
|
||||
} else if (promptResult.status === "failed") {
|
||||
const error = promptResult.error;
|
||||
log("[event] model-fallback promptAsync failed", { sessionID, source, error });
|
||||
} else {
|
||||
log("[event] model-fallback promptAsync skipped by gate", { sessionID, source, status: promptResult.status });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await pluginContext.client.session.prompt(promptBody).then(() => {
|
||||
dispatched = true;
|
||||
}).catch((error) => {
|
||||
log("[event] model-fallback prompt failed", { sessionID, source, error });
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
client: pluginContext.client,
|
||||
sessionID,
|
||||
source: `model-fallback:${source}:sync`,
|
||||
input: promptBody,
|
||||
});
|
||||
if (promptResult.status === "dispatched") {
|
||||
dispatched = true;
|
||||
} else if (promptResult.status === "failed") {
|
||||
log("[event] model-fallback prompt failed", { sessionID, source, error: promptResult.error });
|
||||
} else {
|
||||
log("[event] model-fallback prompt skipped by gate", { sessionID, source, status: promptResult.status });
|
||||
}
|
||||
} finally {
|
||||
if (dispatched && fallbackKeys.modelKey) {
|
||||
const dispatchedKeys = getFallbackContinuationDedupeState(sessionID);
|
||||
@@ -898,13 +918,21 @@ export function createEventHandler(args: {
|
||||
log("[event] compaction before recovery continue failed:", { sessionID, error: err });
|
||||
});
|
||||
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
client: pluginContext.client,
|
||||
sessionID,
|
||||
source: "session-recovery:post-compaction-continue",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: { parts: [createInternalAgentContinuationTextPart("continue")] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
if (promptResult.status === "failed") {
|
||||
log("[event] recovery continue prompt failed", { sessionID, error: promptResult.error });
|
||||
} else if (promptResult.status !== "dispatched") {
|
||||
log("[event] recovery continue prompt skipped by gate", { sessionID, status: promptResult.status });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PluginContext } from "./types"
|
||||
|
||||
import { createUnstableAgentBabysitterHook } from "../hooks"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { promptAsyncAfterSessionIdle } from "../hooks/shared/prompt-async-gate"
|
||||
|
||||
export function createUnstableAgentBabysitter(args: {
|
||||
ctx: PluginContext
|
||||
@@ -24,11 +25,28 @@ export function createUnstableAgentBabysitter(args: {
|
||||
}
|
||||
return []
|
||||
},
|
||||
status: async () => ctx.client.session.status(),
|
||||
prompt: async (promptArgs) => {
|
||||
await ctx.client.session.promptAsync(promptArgs)
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID: promptArgs.path.id,
|
||||
source: "unstable-agent-babysitter",
|
||||
input: promptArgs,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
},
|
||||
promptAsync: async (promptArgs) => {
|
||||
await ctx.client.session.promptAsync(promptArgs)
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID: promptArgs.path.id,
|
||||
source: "unstable-agent-babysitter",
|
||||
input: promptArgs,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -230,6 +230,67 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should reject 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) => {
|
||||
releasePrompt = resolve
|
||||
})
|
||||
const promptMock = mock(async () => {
|
||||
await promptGate
|
||||
})
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "session-dup": { type: "idle" } } }),
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "session-dup" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
}
|
||||
|
||||
// when both callers try to prompt the same session before the first dispatch settles
|
||||
const first = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
await Promise.resolve()
|
||||
const second = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
releasePrompt?.()
|
||||
const results = await Promise.allSettled([first, second])
|
||||
|
||||
// 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")
|
||||
})
|
||||
|
||||
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "session-post-dispatch-hold" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const second = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
|
||||
// then
|
||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should throw error from promptAsync directly on model-not-found error", async () => {
|
||||
// given a client that fails with model-not-found error
|
||||
const promptMock = mock().mockRejectedValueOnce({
|
||||
@@ -400,6 +461,31 @@ 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 () => {
|
||||
// given
|
||||
const promptMock = mock(async () => undefined)
|
||||
const client = {
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "session-sync-post-dispatch-hold" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
const second = promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||
|
||||
// then
|
||||
await expect(second).rejects.toThrow("prompt skipped by gate: reserved")
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should abort and throw timeout error when sync prompt hangs", async () => {
|
||||
// given a client where sync prompt never resolves unless aborted
|
||||
let receivedSignal: AbortSignal | undefined
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PROMPT_TIMEOUT_MS,
|
||||
type PromptRetryOptions,
|
||||
} from "./prompt-timeout-context"
|
||||
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle } from "./prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -93,14 +94,24 @@ export async function promptWithModelSuggestionRetry(
|
||||
): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS
|
||||
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
|
||||
// model errors happen asynchronously server-side and cannot be caught here
|
||||
const promptPromise = client.session.promptAsync({
|
||||
...args,
|
||||
signal: timeoutContext.signal,
|
||||
} as Parameters<typeof client.session.promptAsync>[0])
|
||||
|
||||
try {
|
||||
await promptPromise
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: args.path.id,
|
||||
input: {
|
||||
...args,
|
||||
signal: timeoutContext.signal,
|
||||
} as Parameters<typeof client.session.promptAsync>[0],
|
||||
source: "model-suggestion-retry",
|
||||
settleMs: 0,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
|
||||
}
|
||||
@@ -124,10 +135,23 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
try {
|
||||
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
|
||||
try {
|
||||
await client.session.prompt({
|
||||
...args,
|
||||
signal: timeoutContext.signal,
|
||||
} as Parameters<typeof client.session.prompt>[0])
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: args.path.id,
|
||||
input: {
|
||||
...args,
|
||||
signal: timeoutContext.signal,
|
||||
} as Parameters<typeof client.session.prompt>[0],
|
||||
source: "model-suggestion-retry:sync",
|
||||
settleMs: 0,
|
||||
checkStatus: false,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
throw new Error(`prompt timed out after ${timeoutMs}ms`)
|
||||
}
|
||||
@@ -163,10 +187,23 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
|
||||
const timeoutContext = createPromptTimeoutContext(retryArgs, timeoutMs)
|
||||
try {
|
||||
await client.session.prompt({
|
||||
...retryArgs,
|
||||
signal: timeoutContext.signal,
|
||||
} as Parameters<typeof client.session.prompt>[0])
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: retryArgs.path.id,
|
||||
input: {
|
||||
...retryArgs,
|
||||
signal: timeoutContext.signal,
|
||||
} as Parameters<typeof client.session.prompt>[0],
|
||||
source: "model-suggestion-retry:sync-retry",
|
||||
settleMs: 0,
|
||||
checkStatus: false,
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
throw new Error(`prompt timed out after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { log } from "./logger"
|
||||
import {
|
||||
DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
isSessionActive,
|
||||
settleAfterSessionIdle,
|
||||
} from "./session-idle-settle"
|
||||
|
||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path?: { id?: string }
|
||||
body?: unknown
|
||||
query?: unknown
|
||||
signal?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type PromptAsyncClient<TInput> = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
promptAsync?: (input: TInput) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type PromptClient<TInput> = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
prompt?: (input: TInput) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type PromptAsyncReservation = {
|
||||
source: string
|
||||
reservedAt: number
|
||||
token: symbol
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export type PromptAsyncGateResult =
|
||||
| { status: "dispatched"; response: unknown }
|
||||
| { status: "active" }
|
||||
| { status: "reserved"; reservedBy: string }
|
||||
| { status: "unavailable" }
|
||||
| { status: "failed"; error: unknown }
|
||||
|
||||
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
||||
|
||||
function pruneExpiredReservations(now = Date.now()): void {
|
||||
for (const [sessionID, reservation] of promptAsyncReservations) {
|
||||
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
log("[prompt-async-gate] expired reservation released", {
|
||||
sessionID,
|
||||
source: reservation.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
|
||||
pruneExpiredReservations()
|
||||
return promptAsyncReservations.get(sessionID)
|
||||
}
|
||||
|
||||
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
client: PromptAsyncClient<TInput>
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
checkStatus?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
|
||||
if (typeof client.session?.promptAsync !== "function") {
|
||||
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] promptAsync skipped because session is reserved", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let holdReservationAfterDispatch = false
|
||||
|
||||
try {
|
||||
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log("[prompt-async-gate] promptAsync skipped because session is active", { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log("[prompt-async-gate] promptAsync dispatching", { sessionID, source })
|
||||
const response = await client.session.promptAsync(input)
|
||||
if (postDispatchHoldMs > 0) {
|
||||
holdReservationAfterDispatch = true
|
||||
}
|
||||
log("[prompt-async-gate] promptAsync dispatched", { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] promptAsync failed", { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
client: PromptClient<TInput>
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
checkStatus?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
|
||||
if (typeof client.session?.prompt !== "function") {
|
||||
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] prompt skipped because session is reserved", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let holdReservationAfterDispatch = false
|
||||
|
||||
try {
|
||||
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log("[prompt-async-gate] prompt skipped because session is active", { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log("[prompt-async-gate] prompt dispatching", { sessionID, source })
|
||||
const response = await client.session.prompt(input)
|
||||
if (postDispatchHoldMs > 0) {
|
||||
holdReservationAfterDispatch = true
|
||||
}
|
||||
log("[prompt-async-gate] prompt dispatched", { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] prompt failed", { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||
promptAsyncReservations.clear()
|
||||
}
|
||||
|
||||
export function releasePromptAsyncReservation(sessionID: string, source: string): void {
|
||||
const existing = promptAsyncReservations.get(sessionID)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
log("[prompt-async-gate] promptAsync reservation released", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const PROMPT_GATE_FILE = path.join(SOURCE_ROOT, "shared", "prompt-async-gate.ts")
|
||||
|
||||
async function listSourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const nestedFiles = await Promise.all(entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return listSourceFiles(entryPath)
|
||||
}
|
||||
if (
|
||||
entry.isFile()
|
||||
&& entry.name.endsWith(".ts")
|
||||
&& !entry.name.endsWith(".test.ts")
|
||||
&& !entry.name.endsWith(".d.ts")
|
||||
) {
|
||||
return [entryPath]
|
||||
}
|
||||
return []
|
||||
}))
|
||||
|
||||
return nestedFiles.flat()
|
||||
}
|
||||
|
||||
function relativeSourcePath(filePath: string): string {
|
||||
return path.relative(SOURCE_ROOT, filePath)
|
||||
}
|
||||
|
||||
function uncommentedLines(contents: string): string[] {
|
||||
return contents
|
||||
.split("\n")
|
||||
.map((line) => line.trimStart())
|
||||
.filter((line) => !line.startsWith("//") && !line.startsWith("*"))
|
||||
}
|
||||
|
||||
describe("production prompt injection routes", () => {
|
||||
test("#given production TypeScript sources #when prompt routes are audited #then only the shared gate may call raw OpenCode prompt APIs", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
if (filePath === PROMPT_GATE_FILE) {
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
if (/\bsession\.promptAsync\s*\(/.test(contents) || /\bsession\.prompt\s*\(/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test("#given production TypeScript sources #when prompt gate callers are audited #then callers cannot disable the post-dispatch reservation hold", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
if (/postDispatchHoldMs\s*:\s*0\b/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150
|
||||
|
||||
export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise<void> {
|
||||
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
|
||||
}
|
||||
|
||||
type SessionStatusClient = {
|
||||
session?: {
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIVE_SESSION_STATUSES = new Set(["busy", "retry", "running"])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getSessionStatusPayload(response: unknown): Record<string, unknown> {
|
||||
if (isRecord(response) && isRecord(response.data)) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
if (isRecord(response)) {
|
||||
return response
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export function isActiveSessionStatusType(statusType: string): boolean {
|
||||
return ACTIVE_SESSION_STATUSES.has(statusType)
|
||||
}
|
||||
|
||||
export async function isSessionActive(client: SessionStatusClient, sessionID: string): Promise<boolean> {
|
||||
if (typeof client.session?.status !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await client.session.status()
|
||||
const status = getSessionStatusPayload(statusResult)[sessionID]
|
||||
if (!isRecord(status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const statusType = status.type
|
||||
return typeof statusType === "string" && isActiveSessionStatusType(statusType)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldPromptAfterSessionIdle(
|
||||
client: SessionStatusClient,
|
||||
sessionID: string,
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
): Promise<boolean> {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
return !(await isSessionActive(client, sessionID))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
|
||||
import { promptAsyncInDirectory } from "./session-route"
|
||||
|
||||
describe("promptAsyncInDirectory", () => {
|
||||
test("#given no session id is present #when routing a promptAsync request #then the helper rejects instead of using an ungated raw prompt", async () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => ({ data: "sent" }))
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
}
|
||||
|
||||
// when, then
|
||||
await expect(
|
||||
promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
),
|
||||
).rejects.toThrow("session id is required for routed promptAsync")
|
||||
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 () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => ({ data: "sent" }))
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "ses_route_hold" },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
)
|
||||
const second = promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(first).toEqual({ data: "sent" })
|
||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
promptSyncWithModelSuggestionRetry,
|
||||
promptWithModelSuggestionRetry,
|
||||
} from "./model-suggestion-retry"
|
||||
import { promptAsyncAfterSessionIdle } from "./prompt-async-gate"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -52,7 +53,27 @@ export function promptAsyncInDirectory(
|
||||
args: PromptAsyncArgs,
|
||||
directory: string,
|
||||
): Promise<unknown> {
|
||||
return client.session.promptAsync(routeSessionPrompt(args, directory))
|
||||
const routedArgs = routeSessionPrompt(args, directory)
|
||||
const sessionID = routedArgs.path?.id
|
||||
if (!sessionID) {
|
||||
return Promise.reject(new Error("session id is required for routed promptAsync"))
|
||||
}
|
||||
|
||||
return promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID,
|
||||
input: routedArgs,
|
||||
source: "session-route",
|
||||
settleMs: 0,
|
||||
}).then((result) => {
|
||||
if (result.status === "failed") {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== "dispatched") {
|
||||
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
||||
}
|
||||
return result.response
|
||||
})
|
||||
}
|
||||
|
||||
export function promptWithRetryInDirectory(
|
||||
|
||||
@@ -79,11 +79,15 @@ function createToolContext(): ToolContext {
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(promptAsync: ReturnType<typeof mock>) {
|
||||
function createContext(
|
||||
promptAsync: ReturnType<typeof mock>,
|
||||
status?: () => Promise<unknown>,
|
||||
) {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync,
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -350,6 +354,70 @@ describe("executeSync", () => {
|
||||
expect(deps.processMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not send a duplicate sync prompt when a reused session is active", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
const deps = createDependencies({
|
||||
createOrGetSession: mock(async () => ({ sessionID: "ses-active-reuse", isNew: false })),
|
||||
})
|
||||
const toolContext = createToolContext()
|
||||
const recorder = createPromptAsyncRecorder()
|
||||
const args = {
|
||||
subagent_type: "explore",
|
||||
description: "active reuse",
|
||||
prompt: "find something",
|
||||
run_in_background: false,
|
||||
session_id: "ses-active-reuse",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSync(
|
||||
args,
|
||||
toolContext,
|
||||
createContext(
|
||||
recorder.promptAsync,
|
||||
async () => ({ data: { "ses-active-reuse": { type: "busy" } } }),
|
||||
) as never,
|
||||
deps,
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(recorder.promptAsync).toHaveBeenCalledTimes(0)
|
||||
expect(result).toContain("Error: Failed to send prompt")
|
||||
expect(result).toContain("session_id: ses-active-reuse")
|
||||
expect(deps.waitForCompletion).not.toHaveBeenCalled()
|
||||
expect(deps.processMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("#given a reused sync session was just prompted #when executeSync is called again immediately #then the second prompt is rejected by the shared gate", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
const deps = createDependencies({
|
||||
createOrGetSession: mock(async () => ({ sessionID: "ses-reused-hold", isNew: false })),
|
||||
})
|
||||
const toolContext = createToolContext()
|
||||
const recorder = createPromptAsyncRecorder()
|
||||
const args = {
|
||||
subagent_type: "explore",
|
||||
description: "reused hold",
|
||||
prompt: "find something",
|
||||
run_in_background: false,
|
||||
session_id: "ses-reused-hold",
|
||||
}
|
||||
const context = createContext(recorder.promptAsync) as never
|
||||
|
||||
//#when
|
||||
const first = await executeSync(args, toolContext, context, deps)
|
||||
const second = await executeSync(args, toolContext, context, deps)
|
||||
|
||||
//#then
|
||||
expect(first).toContain("agent response")
|
||||
expect(second).toContain("promptAsync skipped by gate: reserved")
|
||||
expect(recorder.promptAsync).toHaveBeenCalledTimes(1)
|
||||
expect(deps.waitForCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(deps.processMessages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("commits reserved descendant quota after creating a new sync session", async () => {
|
||||
//#given
|
||||
const { executeSync } = require("./sync-executor")
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||
import { waitForCompletion } from "./completion-poller"
|
||||
import { processMessages } from "./message-processor"
|
||||
import { createOrGetSession } from "./session-creator"
|
||||
@@ -110,21 +111,33 @@ export async function executeSync(
|
||||
return `Error: Failed to send prompt: promptAsync is not available on this OpenCode client.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: normalizedSubagentType,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(normalizedSubagentType),
|
||||
task: false,
|
||||
question: false,
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "call-omo-agent:sync",
|
||||
settleMs: 0,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: normalizedSubagentType,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(normalizedSubagentType),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
|
||||
...(model?.variant ? { variant: model.variant } : {}),
|
||||
...buildPromptGenerationParams(model),
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
|
||||
...(model?.variant ? { variant: model.variant } : {}),
|
||||
...buildPromptGenerationParams(model),
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { _resetTaskToastManagerForTesting as resetTaskToastManager } from "./src
|
||||
import { _resetForTesting as resetModelFallbackState } from "./src/hooks/model-fallback/hook"
|
||||
import { _resetMemCacheForTesting as resetConnectedProvidersCache } from "./src/shared/connected-providers-cache"
|
||||
import { getOmoOpenCodeCacheDir } from "./src/shared/data-path"
|
||||
import { releaseAllPromptAsyncReservationsForTesting } from "./src/shared/prompt-async-gate"
|
||||
import { installModuleMockLifecycle } from "./src/testing/module-mock-lifecycle"
|
||||
|
||||
const { restoreModuleMocks } = installModuleMockLifecycle(mock)
|
||||
@@ -25,6 +26,7 @@ beforeEach(() => {
|
||||
resetTaskToastManager()
|
||||
resetModelFallbackState()
|
||||
resetConnectedProvidersCache()
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -53,6 +55,7 @@ afterEach(() => {
|
||||
cleanupOmoCacheDir(getOmoOpenCodeCacheDir())
|
||||
resetTaskToastManager()
|
||||
resetConnectedProvidersCache()
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
mock.restore()
|
||||
restoreModuleMocks()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user