diff --git a/README.md b/README.md index 9c52d73..74cf931 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Server options can be configured in `opencode.json`: "defer_while_tasks_active": true, "max_auto_turns": 25, "min_continue_interval_seconds": 3, + "max_turn_time": 300, "max_prompt_failures": 3, "default_token_budget": 200000, "max_goal_duration_seconds": 1800, @@ -105,6 +106,7 @@ Defaults: - `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt. - `max_auto_turns`: `25` - `min_continue_interval_seconds`: `3` +- `max_turn_time`: unset by default; set a positive number of seconds to retry one active-goal continuation prompt when a model turn remains busy for that long. Each new busy event resets the watchdog. Idle, built-in retry, session deletion, active Task children, and restricted agents suppress the retry. Watchdog retries are independent of `min_continue_interval_seconds` and do not consume auto-turn, no-progress, or prompt-failure budgets. - `max_prompt_failures`: `3` - `default_token_budget`: unset by default; when set, new goals inherit this token budget. - `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit. @@ -174,7 +176,7 @@ The state file is written atomically with owner-only permissions when the host f ## Credits -This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from Willy Topete's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, Willy. +This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from William Ricchiuti's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, William. ## Development @@ -213,6 +215,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul } ``` -Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode idle events, including `session.idle` and `session.status` idle notifications. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative. +Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode idle events, including `session.idle` and `session.status` idle notifications. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn, no-progress, or prompt-failure budgets. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative. The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. Closed goals remain visible briefly through the latest tool state as achieved or unmet. diff --git a/dist/server.js b/dist/server.js index e1e1520..c0168a5 100644 --- a/dist/server.js +++ b/dist/server.js @@ -792,6 +792,7 @@ var DEFAULT_RESTRICTED_AGENTS = ["plan"]; var GOAL_SYSTEM_MARKER = "OpenCode goal mode"; var TASK_SETTLE_DELAY_MS = 25; var SNAPSHOT_IDLE_HOLD_MS = 250; +var MAX_TIMER_DELAY_MS = 2147483647; var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]); var PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.'; var activeContinuations = new Set; @@ -833,6 +834,11 @@ function commandNameFromOptions(options) { function positiveIntegerOrNull2(value) { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null; } +function timeoutMillisecondsFromSeconds(value) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) + return null; + return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS); +} function registerDesktopCommand(config, commandName) { config.command ??= {}; if (config.command[commandName]) @@ -1002,8 +1008,12 @@ function sessionIDFromEvent(event) { if (typeof direct === "string") return direct; const info = event.properties?.info; - if (typeof info === "object" && info !== null && typeof info.sessionID === "string") { - return info.sessionID; + if (typeof info === "object" && info !== null) { + if (typeof info.sessionID === "string") + return info.sessionID; + if (event.type === "session.deleted" && typeof info.id === "string") { + return info.id; + } } return; } @@ -1304,12 +1314,14 @@ var server = async ({ client }, options) => { const deferWhileTasksActive = options?.defer_while_tasks_active ?? true; const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS; const minInterval = positiveIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS; + const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time); const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options?.register_command ?? true; const commandName = commandNameFromOptions(options); const taskTracker = new TaskTracker; const taskDeferredSessions = new Set; const scheduledContinuations = new Map; + const turnWatchdogs = new Map; const busySessions = new Set; const planAgents = restrictedAgentSet(options); const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()); @@ -1335,6 +1347,75 @@ var server = async ({ client }, options) => { retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID) }; } + function clearTurnWatchdog(sessionID) { + const watchdog = turnWatchdogs.get(sessionID); + if (!watchdog) + return; + clearTimeout(watchdog.timer); + turnWatchdogs.delete(sessionID); + } + function armTurnWatchdog(sessionID) { + if (maxTurnTimeMs == null) + return; + clearTurnWatchdog(sessionID); + const watchdog = { + timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs) + }; + const maybeUnref = watchdog.timer; + if (typeof maybeUnref.unref === "function") + maybeUnref.unref(); + turnWatchdogs.set(sessionID, watchdog); + } + async function runTurnWatchdog(sessionID, watchdog) { + let claimedContinuation = false; + try { + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) + return; + const goal = await getGoal(sessionID); + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) + return; + if (goal?.status !== "active" || isPlanAgent(goal.lastPromptAgent)) + return; + const latestAssistant = await fetchLatestAssistant(client, sessionID); + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) + return; + const latestTurnAgent = agentFromMessage(latestAssistant); + if (isPlanAgent(latestTurnAgent)) + return; + const taskStatus = await taskBlockStatus(sessionID); + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) + return; + if (taskStatus && taskStatus.blocked) + return; + const current = await getGoal(sessionID); + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) + return; + if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuations.has(sessionID)) + return; + turnWatchdogs.delete(sessionID); + activeContinuations.add(sessionID); + claimedContinuation = true; + await sendContinuation(client, sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestTurnAgent ?? null); + } catch (error) { + try { + await client.app?.log?.({ + body: { + service: "opencode-goal-plugin", + level: "error", + message: "Turn watchdog retry failed", + extra: { error: error instanceof Error ? error.message : String(error) } + } + }); + } catch { + return; + } + } finally { + if (claimedContinuation) + activeContinuations.delete(sessionID); + if (turnWatchdogs.get(sessionID) === watchdog) + turnWatchdogs.delete(sessionID); + } + } function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) { if (scheduledContinuations.has(sessionID)) return; @@ -1406,6 +1487,9 @@ var server = async ({ client }, options) => { for (const timer of scheduledContinuations.values()) clearTimeout(timer); scheduledContinuations.clear(); + for (const watchdog of turnWatchdogs.values()) + clearTimeout(watchdog.timer); + turnWatchdogs.clear(); }, async config(config) { if (!registerCommand) @@ -1560,17 +1644,30 @@ var server = async ({ client }, options) => { if (isRecord(status) && typeof status.type === "string") { if (status.type === "busy") busySessions.add(sessionID); - if (status.type === "idle") + if (status.type === "busy") + armTurnWatchdog(sessionID); + if (status.type === "idle") { busySessions.delete(sessionID); + clearTurnWatchdog(sessionID); + } + if (status.type === "retry") + clearTurnWatchdog(sessionID); taskTracker.observeSessionStatus(sessionID, status.type); } } if (sessionID && eventType === "session.idle") { busySessions.delete(sessionID); + clearTurnWatchdog(sessionID); taskTracker.observeSessionStatus(sessionID, "idle"); } if (sessionID && eventType === "session.deleted") { busySessions.delete(sessionID); + clearTurnWatchdog(sessionID); + const scheduled = scheduledContinuations.get(sessionID); + if (scheduled) + clearTimeout(scheduled); + scheduledContinuations.delete(sessionID); + taskDeferredSessions.delete(sessionID); taskTracker.observeSessionDeleted(sessionID); } if (sessionID && event.type === "message.updated") { diff --git a/src/server.ts b/src/server.ts index b038375..bcb5d2e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,7 @@ type Options = { defer_while_tasks_active?: boolean max_auto_turns?: number min_continue_interval_seconds?: number + max_turn_time?: number max_prompt_failures?: number register_command?: boolean command_name?: string @@ -62,6 +63,7 @@ const DEFAULT_RESTRICTED_AGENTS = ["plan"] const GOAL_SYSTEM_MARKER = "OpenCode goal mode" const TASK_SETTLE_DELAY_MS = 25 const SNAPSHOT_IDLE_HOLD_MS = 250 +const MAX_TIMER_DELAY_MS = 2_147_483_647 const TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]) const PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.' @@ -94,6 +96,10 @@ type SnapshotIdleHold = { expiresAt: number } +type TurnWatchdog = { + timer: ReturnType +} + function restrictedAgentSet(options?: Options) { if (options?.allow_goal_execution_from_plan === true) return new Set() const names = Array.isArray(options?.restricted_agents) ? options.restricted_agents : DEFAULT_RESTRICTED_AGENTS @@ -134,6 +140,11 @@ function positiveIntegerOrNull(value: unknown) { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null } +function timeoutMillisecondsFromSeconds(value: unknown) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null + return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS) +} + function registerDesktopCommand(config: Config, commandName: string) { config.command ??= {} if (config.command[commandName]) return @@ -292,12 +303,15 @@ function isIdleEvent(event: { type?: string; properties?: Record }) { +function sessionIDFromEvent(event: { type?: string; properties?: Record }) { const direct = event.properties?.sessionID if (typeof direct === "string") return direct const info = event.properties?.info - if (typeof info === "object" && info !== null && typeof (info as { sessionID?: unknown }).sessionID === "string") { - return (info as { sessionID: string }).sessionID + if (typeof info === "object" && info !== null) { + if (typeof (info as { sessionID?: unknown }).sessionID === "string") return (info as { sessionID: string }).sessionID + if (event.type === "session.deleted" && typeof (info as { id?: unknown }).id === "string") { + return (info as { id: string }).id + } } return undefined } @@ -606,12 +620,14 @@ const server: Plugin = async ({ client }, options?: Options) => { const deferWhileTasksActive = options?.defer_while_tasks_active ?? true const maxAutoTurns = positiveIntegerOrNull(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS const minInterval = positiveIntegerOrNull(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS + const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time) const maxPromptFailures = positiveIntegerOrNull(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES const registerCommand = options?.register_command ?? true const commandName = commandNameFromOptions(options) const taskTracker = new TaskTracker() const taskDeferredSessions = new Set() const scheduledContinuations = new Map>() + const turnWatchdogs = new Map() const busySessions = new Set() const planAgents = restrictedAgentSet(options) const isPlanAgent = (agent: unknown) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()) @@ -639,6 +655,65 @@ const server: Plugin = async ({ client }, options?: Options) => { } } + function clearTurnWatchdog(sessionID: string) { + const watchdog = turnWatchdogs.get(sessionID) + if (!watchdog) return + clearTimeout(watchdog.timer) + turnWatchdogs.delete(sessionID) + } + + function armTurnWatchdog(sessionID: string) { + if (maxTurnTimeMs == null) return + clearTurnWatchdog(sessionID) + const watchdog: TurnWatchdog = { + timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs), + } + const maybeUnref = watchdog.timer as { unref?: () => void } + if (typeof maybeUnref.unref === "function") maybeUnref.unref() + turnWatchdogs.set(sessionID, watchdog) + } + + async function runTurnWatchdog(sessionID: string, watchdog: TurnWatchdog) { + let claimedContinuation = false + try { + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return + const goal = await getGoal(sessionID) + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return + if (goal?.status !== "active" || isPlanAgent(goal.lastPromptAgent)) return + const latestAssistant = await fetchLatestAssistant(client, sessionID) + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return + const latestTurnAgent = agentFromMessage(latestAssistant) + if (isPlanAgent(latestTurnAgent)) return + const taskStatus = await taskBlockStatus(sessionID) + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return + if (taskStatus && taskStatus.blocked) return + const current = await getGoal(sessionID) + if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return + if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuations.has(sessionID)) return + + turnWatchdogs.delete(sessionID) + activeContinuations.add(sessionID) + claimedContinuation = true + await sendContinuation(client, sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestTurnAgent ?? null) + } catch (error) { + try { + await client.app?.log?.({ + body: { + service: "opencode-goal-plugin", + level: "error", + message: "Turn watchdog retry failed", + extra: { error: error instanceof Error ? error.message : String(error) }, + }, + }) + } catch { + return + } + } finally { + if (claimedContinuation) activeContinuations.delete(sessionID) + if (turnWatchdogs.get(sessionID) === watchdog) turnWatchdogs.delete(sessionID) + } + } + function scheduleSettledContinuation(sessionID: string, delayMs = TASK_SETTLE_DELAY_MS) { if (scheduledContinuations.has(sessionID)) return const timer = setTimeout(() => { @@ -706,6 +781,8 @@ const server: Plugin = async ({ client }, options?: Options) => { async dispose() { for (const timer of scheduledContinuations.values()) clearTimeout(timer) scheduledContinuations.clear() + for (const watchdog of turnWatchdogs.values()) clearTimeout(watchdog.timer) + turnWatchdogs.clear() }, async config(config) { if (!registerCommand) return @@ -876,16 +953,27 @@ const server: Plugin = async ({ client }, options?: Options) => { const status = (event as { properties?: Record }).properties?.status if (isRecord(status) && typeof status.type === "string") { if (status.type === "busy") busySessions.add(sessionID) - if (status.type === "idle") busySessions.delete(sessionID) + if (status.type === "busy") armTurnWatchdog(sessionID) + if (status.type === "idle") { + busySessions.delete(sessionID) + clearTurnWatchdog(sessionID) + } + if (status.type === "retry") clearTurnWatchdog(sessionID) taskTracker.observeSessionStatus(sessionID, status.type) } } if (sessionID && eventType === "session.idle") { busySessions.delete(sessionID) + clearTurnWatchdog(sessionID) taskTracker.observeSessionStatus(sessionID, "idle") } if (sessionID && eventType === "session.deleted") { busySessions.delete(sessionID) + clearTurnWatchdog(sessionID) + const scheduled = scheduledContinuations.get(sessionID) + if (scheduled) clearTimeout(scheduled) + scheduledContinuations.delete(sessionID) + taskDeferredSessions.delete(sessionID) taskTracker.observeSessionDeleted(sessionID) } if (sessionID && (event as { type?: string }).type === "message.updated") { diff --git a/test/server.test.ts b/test/server.test.ts index ef9800f..2f37438 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -441,6 +441,221 @@ test("session status idle event auto-continues active goals", async () => { expect(calls).toHaveLength(1) }) +test("turn watchdog retries a busy active goal without consuming continuation budgets", async () => { + const calls: { body?: { agent?: string; parts?: { text?: string }[] } }[] = [] + const hooks = await plugin.server( + { + client: { + session: { + promptAsync: async (input: unknown) => { + calls.push(input as { body?: { agent?: string; parts?: { text?: string }[] } }) + }, + }, + }, + } as never, + { auto_continue: false, max_turn_time: 0.02, max_auto_turns: 1, max_prompt_failures: 1 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + const context = { sessionID: "ses_1", agent: "build" } as never + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, context) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } } as never, + }) + + await waitForContinuation(calls) + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(calls).toHaveLength(1) + expect(calls[0]?.body?.agent).toBe("build") + expect(calls[0]?.body?.parts?.[0]?.text).toContain("Continue working toward the active session goal") + const read = await requireTool(tools.get_goal, "get_goal").execute({}, context) + expect(String(read)).toContain('"status": "active"') + expect(String(read)).toContain('"autoTurns": 0') + expect(String(read)).toContain('"continuationFailures": 0') + expect(String(read)).toContain('"awaitingContinuationProgress": false') + + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } } as never, + }) + await waitFor(() => calls.length === 2) +}) + +test("turn watchdog resets when another busy turn starts", async () => { + const calls: unknown[] = [] + const hooks = await plugin.server( + { + client: { + session: { + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: false, max_turn_time: 0.08 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, { sessionID: "ses_1" } as never) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } } as never, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } } as never, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(calls).toHaveLength(0) + await waitForContinuation(calls) +}) + +test("turn watchdog cancels on idle, retry, deletion, and dispose", async () => { + const calls: unknown[] = [] + const hooks = await plugin.server( + { + client: { + session: { + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: false, max_turn_time: 0.02 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + for (const sessionID of ["ses_idle", "ses_retry", "ses_deleted"]) { + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, { sessionID } as never) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID, status: { type: "busy" } } } as never, + }) + } + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_idle" } } as never }) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_retry", status: { type: "retry" } } } as never, + }) + await hooks.event!({ event: { type: "session.deleted", properties: { info: { id: "ses_deleted" } } } as never }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(calls).toHaveLength(0) + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "keep going" }, + { sessionID: "ses_disposed" } as never, + ) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_disposed", status: { type: "busy" } } } as never, + }) + await hooks.dispose?.() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(calls).toHaveLength(0) +}) + +test("turn watchdog does not inject while tasks are active, the goal is paused, or the turn is restricted", async () => { + const calls: unknown[] = [] + const hooks = await plugin.server( + { + client: { + session: { + messages: async (input: { path: { id: string } }) => ({ + data: + input.path.id === "ses_latest_plan" + ? [ + { + info: { id: "msg_plan", role: "assistant", sessionID: "ses_latest_plan", mode: "plan" }, + parts: [], + }, + ] + : [], + }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: false, max_turn_time: 0.02 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "task goal" }, + { sessionID: "ses_task", agent: "build" } as never, + ) + await hooks["tool.execute.after"]?.( + { tool: "Task", sessionID: "ses_task", callID: "call_1", args: {} } as never, + { title: "Task", output: "task_id: task_1\nstate: running", metadata: {} } as never, + ) + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "restricted goal" }, + { sessionID: "ses_plan", agent: "build" } as never, + ) + await hooks["chat.message"]!( + { sessionID: "ses_plan", agent: "plan" } as never, + { message: { sessionID: "ses_plan", agent: "plan" }, parts: [] } as never, + ) + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "latest restricted turn" }, + { sessionID: "ses_latest_plan", agent: "build" } as never, + ) + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "paused goal" }, + { sessionID: "ses_paused", agent: "build" } as never, + ) + await requireTool(tools.update_goal_status, "update_goal_status").execute( + { status: "paused" }, + { sessionID: "ses_paused", agent: "build" } as never, + ) + for (const sessionID of ["ses_task", "ses_plan", "ses_latest_plan", "ses_paused"]) { + await hooks.event!({ + event: { type: "session.status", properties: { sessionID, status: { type: "busy" } } } as never, + }) + } + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(calls).toHaveLength(0) +}) + +test("turn watchdog transport failures do not pause or charge the goal", async () => { + const logs: unknown[] = [] + const hooks = await plugin.server( + { + client: { + app: { log: async (input: unknown) => logs.push(input) }, + session: { + promptAsync: async () => { + throw new Error("network down") + }, + }, + }, + } as never, + { auto_continue: false, max_turn_time: 0.02, max_prompt_failures: 1 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + const context = { sessionID: "ses_1" } as never + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, context) + await hooks.event!({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } } as never, + }) + await waitFor(() => logs.length === 1) + + const read = await requireTool(tools.get_goal, "get_goal").execute({}, context) + expect(String(read)).toContain('"status": "active"') + expect(String(read)).toContain('"autoTurns": 0') + expect(String(read)).toContain('"continuationFailures": 0') + expect(JSON.stringify(logs[0])).toContain("Turn watchdog retry failed") +}) + test("running task defers idle auto-continue", async () => { const calls: unknown[] = [] const hooks = await plugin.server(