diff --git a/README.md b/README.md index d52a2a8..727e1ee 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,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` +- Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up. - `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 never consume auto-turn or no-progress budgets, but recognized transport failures still count toward the `max_prompt_failures` ceiling. - `max_prompt_failures`: `3`; consecutive transport or no-response continuation failures pause the goal at this ceiling. Prompt delivery alone does not reset the count; substantive assistant or tool progress, a new goal, or an explicit resume does. - `default_token_budget`: unset by default; when set, new goals inherit this token budget. @@ -253,6 +254,17 @@ bun run build npm pack --dry-run ``` +With `opencode2` installed, run `bun run build && bun run smoke:v2` to exercise the +native V2 lifecycle, not just mocked events. The smoke test uses a private server, +isolated home/config/database/goal state, and a deterministic local model with no +real provider credentials. It invokes `/goal`, requires **two** automatic +continuations with the default minimum interval, then verifies completion. A +second loaded location checks that server-wide events do not duplicate delivery. +The test prints its temporary artifact directory and shuts down its private +server. Use `OPENCODE_V2_BIN` to select another V2 binary, or run +`bun run smoke:v2 @prevalentware/opencode-goal-plugin@` to install and +verify an exact published package in the isolated environment. + ## Publishing This package is set up for npm Trusted Publishing from GitHub Actions. On every push to `main`, CI runs typecheck, lint, and unit tests in parallel. If they all pass, the publish job computes the next patch version from the latest version on npm, builds the package, and runs `npm publish`. @@ -279,6 +291,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. 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. +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 V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. 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 or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. 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 fa2e155..8bf3a49 100644 --- a/dist/server.js +++ b/dist/server.js @@ -2687,6 +2687,7 @@ async function setupV2(context) { const planAgents = restrictedAgentSet(options); const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()); const activeContinuationsV2 = new Set; + const stoppedExecutions = new Set; const latestStepBySession = new Map; const stepTextBuffers = new Map; const stepTokenSums = new Map; @@ -2833,6 +2834,8 @@ async function setupV2(context) { async function runAutoContinue(sessionID, fromTaskDeferral = false, scheduled) { if (disposed) return; + if (stoppedExecutions.has(sessionID)) + return; if (busySessions.has(sessionID)) return; if (activeContinuationsV2.has(sessionID)) @@ -2913,8 +2916,13 @@ async function setupV2(context) { if (nativeRetrySessions.has(sessionID)) return; const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval); - if (!goal) + if (!goal) { + const waiting = await getGoalInternal(sessionID); + if (waiting?.status === "active" && waiting.pendingAttempt == null && waiting.lastContinuationAt != null && minInterval > 0) { + scheduleSettledContinuation(sessionID, continuationDelayFromSnapshot(minInterval, waiting.lastContinuationAt), scheduled != null); + } return; + } attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now(); if (nativeRetrySessions.has(sessionID)) { await rollbackContinuationAttempt(sessionID); @@ -2955,6 +2963,23 @@ async function setupV2(context) { async function handleV2Event(event) { const data = event.data; const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined; + if (context.location && event.location && (event.location.directory !== context.location.directory || event.location.workspaceID !== context.location.workspaceID)) { + if (event.type === "session.created" && sessionID && typeof data.parentID === "string") { + taskTracker.observeSessionCreated({ properties: { info: { id: sessionID, parentID: data.parentID } } }); + } else if (sessionID) { + if (event.type === "session.execution.started") + taskTracker.observeSessionStatus(sessionID, "busy"); + if (["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.idle"].includes(event.type)) { + taskTracker.observeSessionStatus(sessionID, "idle"); + } + if (event.type === "session.status" && isRecord(data.status) && typeof data.status.type === "string") { + taskTracker.observeSessionStatus(sessionID, data.status.type); + } + if (event.type === "session.deleted") + taskTracker.observeSessionDeleted(sessionID); + } + return; + } switch (event.type) { case "session.created": { const parentID = data.parentID; @@ -2963,10 +2988,13 @@ async function setupV2(context) { } return; } + case "session.execution.started": + case "session.retry.scheduled": case "session.status": { - const status = data.status; + const status = event.type === "session.execution.started" ? { type: "busy" } : event.type === "session.retry.scheduled" ? { type: "retry" } : data.status; if (sessionID && isRecord(status) && typeof status.type === "string") { if (status.type === "busy") { + stoppedExecutions.delete(sessionID); busySessions.add(sessionID); nativeRetrySessions.delete(sessionID); armTurnWatchdog(sessionID); @@ -2992,6 +3020,7 @@ async function setupV2(context) { } return; } + case "session.execution.succeeded": case "session.idle": { if (sessionID) { busySessions.delete(sessionID); @@ -3007,9 +3036,25 @@ async function setupV2(context) { } return; } + case "session.execution.interrupted": { + if (!sessionID) + return; + stoppedExecutions.add(sessionID); + busySessions.delete(sessionID); + nativeRetrySessions.delete(sessionID); + clearTurnWatchdog(sessionID); + watchdogRescuedSessions.delete(sessionID); + cancelScheduledContinuation(sessionID); + taskDeferredSessions.delete(sessionID); + taskTracker.observeSessionStatus(sessionID, "idle"); + return; + } + case "session.execution.failed": case "session.error": { if (!sessionID) return; + if (event.type === "session.execution.failed") + nativeRetrySessions.delete(sessionID); const inNativeRetry = nativeRetrySessions.has(sessionID); busySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3018,6 +3063,13 @@ async function setupV2(context) { nativeRetrySessions.delete(sessionID); watchdogRescuedSessions.delete(sessionID); const errorMessage = transportErrorMessageFromEvent(data); + if (event.type === "session.execution.failed" && !isTransportError(errorMessage)) { + stoppedExecutions.add(sessionID); + cancelScheduledContinuation(sessionID); + taskDeferredSessions.delete(sessionID); + } + if (event.type === "session.execution.failed") + taskTracker.observeSessionStatus(sessionID, "idle"); if (errorMessage && isTransportError(errorMessage)) { const goal = await getGoalInternal(sessionID); if (goal?.status === "active") { @@ -3041,6 +3093,7 @@ async function setupV2(context) { case "session.deleted": { if (!sessionID) return; + stoppedExecutions.delete(sessionID); busySessions.delete(sessionID); clearTurnWatchdog(sessionID); watchdogRescuedSessions.delete(sessionID); @@ -3317,6 +3370,7 @@ async function setupV2(context) { clearTimeout(watchdog.timer); turnWatchdogs.clear(); activeContinuationsV2.clear(); + stoppedExecutions.clear(); nativeRetrySessions.clear(); locallyDeliveredPendingSessions.clear(); watchdogRescuedSessions.clear(); diff --git a/package.json b/package.json index 376a030..73fd7c8 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "lint": "eslint .", "pack:dry-run": "npm pack --dry-run", "test": "bun test", + "smoke:v2": "bun scripts/smoke-v2-lifecycle.ts", "test:coverage": "bun test --coverage", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run test && bun run build" diff --git a/scripts/smoke-v2-lifecycle.ts b/scripts/smoke-v2-lifecycle.ts new file mode 100644 index 0000000..5cb4cf9 --- /dev/null +++ b/scripts/smoke-v2-lifecycle.ts @@ -0,0 +1,170 @@ +// Runs the installed OpenCode V2 binary against a deterministic local model. +// No real provider credentials, shared service, or user goal state are used. +import assert from "node:assert/strict" +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { pathToFileURL } from "node:url" + +const root = await mkdtemp(join(tmpdir(), "goal-v2-lifecycle-smoke-")) +const project = join(root, "project") +await mkdir(project) +const target = process.argv[2] ?? "." +const registryPackage = target.startsWith("@") +const packagePath = registryPackage ? target : resolve(target) +let modelCalls = 0 +let continuationCalls = 0 +const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const body = await request.json() as { + messages: Array<{ role: string; content?: unknown }> + tools?: Array<{ function: { name: string } }> + stream?: boolean + } + modelCalls++ + await writeFile(join(root, `model-request-${modelCalls}.json`), JSON.stringify(body)) + const messages = body.messages + const last = messages.at(-1) + const continuationCount = messages.filter((message) => message.role === "user" && + JSON.stringify(message.content).includes("Continue working toward the active session goal")).length + const hasContinuation = continuationCount > 0 + if (hasContinuation) continuationCalls++ + const toolName = hasContinuation ? "update_goal" : "create_goal" + const tool = body.tools?.find((entry) => entry.function.name === toolName || entry.function.name.endsWith(`_${toolName}`)) + const call = last?.role === "user" && tool && (!hasContinuation || continuationCount >= 2) + const args = hasContinuation + ? { status: "complete", evidence: "A native V2 execution settled and the plugin automatically sent the next goal prompt." } + : { objective: "Verify native V2 goal continuation with the local fixture model", max_auto_turns: 3 } + const delta = call + ? { role: "assistant", tool_calls: [{ index: 0, id: `call_${modelCalls}`, type: "function", function: { name: tool.function.name, arguments: JSON.stringify(args) } }] } + : { role: "assistant", content: `Isolated fixture milestone ${modelCalls} is verified. The active goal still requires the next automatic continuation turn.` } + const finish = call ? "tool_calls" : "stop" + const chunk = (choices: unknown[], usage?: unknown) => ({ id: `chatcmpl_${modelCalls}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "fixture", choices, ...(usage ? { usage } : {}) }) + const usage = { prompt_tokens: 100, completion_tokens: 100, total_tokens: 200 } + if (!body.stream) { + return Response.json({ ...chunk([]), object: "chat.completion", choices: [{ index: 0, message: delta, finish_reason: finish }], usage }) + } + return new Response([ + chunk([{ index: 0, delta, finish_reason: null }]), + chunk([{ index: 0, delta: {}, finish_reason: finish }], usage), + ].map((item) => `data: ${JSON.stringify(item)}\n\n`).join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }) + }, +}) + +await mkdir(join(root, "config/opencode"), { recursive: true }) +if (!registryPackage) { + await mkdir(join(root, "config/opencode/plugins")) + await writeFile(join(root, "config/opencode/plugins/goal.ts"), `export { default } from ${JSON.stringify(pathToFileURL(join(packagePath, "dist/server.js")).href)}\n`) +} +await writeFile(join(root, "config/opencode/opencode.json"), JSON.stringify({ + model: "fixture/fixture", + snapshots: false, + providers: { + fixture: { + env: ["FIXTURE_API_KEY"], + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { baseURL: `http://127.0.0.1:${model.port}/v1` }, + models: { fixture: { name: "Local fixture", limit: { context: 100000, output: 1000 } } }, + }, + }, +})) + +// Use a clean environment, not a spread of process.env (which may carry a live +// OPENCODE_DB, server connection settings, provider credentials, or config). +const env = { + PATH: process.env.PATH!, + HOME: join(root, "home"), + XDG_CONFIG_HOME: join(root, "config"), + XDG_DATA_HOME: join(root, "data"), + XDG_STATE_HOME: join(root, "state"), + XDG_CACHE_HOME: join(root, "cache"), + OPENCODE_DB: join(root, "opencode.db"), + OPENCODE_GOAL_STATE_PATH: join(root, "goals.json"), + OPENCODE_PASSWORD: crypto.randomUUID(), + FIXTURE_API_KEY: "local-fixture-only", +} +const binary = process.env.OPENCODE_V2_BIN ?? "opencode2" +if (registryPackage) { + const install = Bun.spawn([binary, "plugin", "add", packagePath], { cwd: project, env, stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr, code] = await Promise.all([new Response(install.stdout).text(), new Response(install.stderr).text(), install.exited]) + await writeFile(join(root, "install.log"), stdout + stderr) + if (code !== 0) { + model.stop(true) + throw new Error(`Plugin installation failed; inspect ${root}/install.log`) + } +} +const child = Bun.spawn([binary, "serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: project, env, stdout: "pipe", stderr: "pipe", +}) +let output = "" +const consume = async (stream: ReadableStream) => { + for await (const chunk of stream) output += new TextDecoder().decode(chunk) +} +const readers = Promise.all([consume(child.stdout), consume(child.stderr)]) +const deadline = Date.now() + Number(process.env.OPENCODE_SMOKE_TIMEOUT_MS ?? 30000) +const waitFor = async (check: () => boolean | Promise) => { + while (Date.now() < deadline) { + if (await check()) return + if (child.exitCode != null) throw new Error(`Private V2 exited: ${output.slice(-4000)}`) + await Bun.sleep(50) + } + throw new Error(`Smoke timeout; modelCalls=${modelCalls}, continuationCalls=${continuationCalls}; logs=${root}/server.log`) +} +try { + await waitFor(() => /http:\/\/127\.0\.0\.1:\d+/.test(output)) + const base = output.match(/http:\/\/127\.0\.0\.1:\d+/)![0] + const api = async (path: string, data?: unknown) => { + const response = await fetch(`${base}${path}`, { + method: data === undefined ? "GET" : "POST", + headers: { "content-type": "application/json", authorization: `Basic ${btoa(`opencode:${env.OPENCODE_PASSWORD}`)}` }, + ...(data === undefined ? {} : { body: JSON.stringify(data) }), + }) + assert(response.ok, `${path}: ${response.status} ${await response.clone().text()}`) + const text = await response.text() + return text ? JSON.parse(text) : undefined + } + const created = await api("/api/session", { location: { directory: project }, title: "Isolated lifecycle smoke", model: { providerID: "fixture", id: "fixture" }, agent: "build" }) as { data: { id: string } } + const sessionID = created.data.id + // The user's shared server hosts many locations. Activating a second plugin + // instance must not duplicate continuation delivery for the first location. + const otherProject = join(root, "other-project") + await mkdir(otherProject) + await api(`/api/plugin/await-activation?location%5Bdirectory%5D=${encodeURIComponent(otherProject)}`, {}) + await api(`/api/plugin/await-activation?location%5Bdirectory%5D=${encodeURIComponent(project)}`, {}) + const plugins = await api(`/api/plugin?location%5Bdirectory%5D=${encodeURIComponent(project)}`) as { data: Array<{ id?: string; state?: { status: string; error?: string } }> } + await writeFile(join(root, "plugins.json"), JSON.stringify(plugins)) + await writeFile(join(root, "config.json"), JSON.stringify(await api(`/api/config?location%5Bdirectory%5D=${encodeURIComponent(project)}`))) + assert(plugins.data.some((plugin) => plugin.id === "local.goal-mode.server" && plugin.state?.status === "active"), `Goal plugin did not activate; inspect ${root}/plugins.json`) + const commands = await api(`/api/command?location%5Bdirectory%5D=${encodeURIComponent(project)}`) as { data: Array<{ name: string }> } + assert(commands.data.some((command) => command.name === "goal")) + await api(`/api/session/${sessionID}/command`, { + command: "goal", text: "Create a goal for the fixture milestone. Keep it active until the automatic continuation arrives.", + files: [], agents: [], skills: [], + }) + let state: { goals: Record } | undefined + await waitFor(async () => { + const current = await api(`/api/session/${sessionID}`) as { data: { outcome?: string } } + if (current.data.outcome === "failed") { + const exported = await api(`/api/session/${sessionID}/export`) + await writeFile(join(root, "failed-session.json"), JSON.stringify(exported)) + throw new Error(`Fixture session failed; inspect ${root}/failed-session.json`) + } + try { state = JSON.parse(await readFile(env.OPENCODE_GOAL_STATE_PATH, "utf8")) } catch { return false } + if (state?.goals[sessionID]?.status !== "complete") return false + const active = await api("/api/session/active") as { data: Record } + return !(sessionID in active.data) + }) + assert.equal(state!.goals[sessionID]!.autoTurns, 2) + assert(continuationCalls > 0) + console.log(JSON.stringify({ result: "PASS", packagePath, sessionID, modelCalls, continuationCalls, status: state!.goals[sessionID]!.status, autoTurns: state!.goals[sessionID]!.autoTurns, artifacts: root }, null, 2)) +} finally { + child.kill() + await child.exited + await readers + await writeFile(join(root, "server.log"), output) + model.stop(true) +} diff --git a/src/server.ts b/src/server.ts index 50b6f41..4effa3d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -995,6 +995,7 @@ type V2EventLike = { type: string created: number data: Record + location?: { directory?: string; workspaceID?: string } } function decodeV2Event(value: unknown): V2EventLike | undefined { @@ -1728,6 +1729,10 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()) const activeContinuationsV2 = new Set() + // Interruptions and terminal failures are not successful idle boundaries. + // Keep legacy idle notifications and queued recovery from restarting them; + // only a new execution started by the host may lift this local suppression. + const stoppedExecutions = new Set() const latestStepBySession = new Map() const stepTextBuffers = new Map() const stepTokenSums = new Map() @@ -1871,6 +1876,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise 0) { + scheduleSettledContinuation( + sessionID, + continuationDelayFromSnapshot(minInterval, waiting.lastContinuationAt), + scheduled != null, + ) + } + return + } attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now() if (nativeRetrySessions.has(sessionID)) { await rollbackContinuationAttempt(sessionID) @@ -2012,6 +2031,27 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise Promise } function controlledStream() { - const queue: Array<{ done: boolean; value?: unknown }> = [] + const queue: Array<{ done: boolean; value?: unknown; processed?: () => void }> = [] const waiters: Array<() => void> = [] let ended = false return { push(value: unknown) { - if (ended) return - queue.push({ done: false, value }) + if (ended) return Promise.resolve() + const processed = new Promise((resolve) => queue.push({ done: false, value, processed: resolve })) waiters.shift()?.() + return processed }, end() { if (ended) return @@ -71,6 +72,7 @@ function controlledStream() { if (item) { if (item.done) return yield item.value + item.processed?.() continue } await new Promise((resolve) => waiters.push(resolve)) @@ -170,7 +172,7 @@ function toolContext(sessionID = "ses_v2", agent = "build") { } async function waitFor(predicate: () => boolean | Promise) { - const deadline = Date.now() + 2000 + const deadline = Date.now() + 3000 while (Date.now() < deadline) { if (await predicate()) return await new Promise((resolve) => setTimeout(resolve, 5)) @@ -795,6 +797,55 @@ test("V2 failed steps account usage and replace stale assistant progress", async await cleanup() }) +test("V2 execution success continues an active goal and starts the delivered attempt", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "continue after the current V2 runner settles") + + mock.stream.push({ type: "session.execution.started", created: Date.now(), data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.execution.succeeded", created: Date.now(), data: { sessionID: "ses_v2" } }) + await waitFor(() => mock.promptCalls.length === 1) + await waitFor(async () => (await getGoalInternal("ses_v2"))?.pendingAttempt?.delivered === true) + mock.stream.push({ type: "session.execution.started", created: Date.now(), data: { sessionID: "ses_v2" } }) + await waitFor(async () => (await getGoalInternal("ses_v2"))?.pendingAttempt?.started === true) + expect((await getGoal("ses_v2"))?.autoTurns).toBe(1) + + mock.stream.end() + await cleanup() +}) + +test("V2 global execution events only continue goals in the plugin instance location", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0 }) + const location = { directory: "/workspace/albus", workspaceID: "workspace_a" } + const cleanup = await setupPlugin({ ...mock, location } as never) + await createGoalViaV2Tool(mock, "only the owning location may continue this session") + await mock.stream.push({ type: "session.execution.succeeded", created: 1, location: { ...location, directory: "/workspace/other" }, data: { sessionID: "ses_v2" } }) + await mock.stream.push({ type: "session.execution.succeeded", created: 2, location: { ...location, workspaceID: "workspace_b" }, data: { sessionID: "ses_v2" } }) + expect(mock.promptCalls).toHaveLength(0) + await mock.stream.push({ type: "session.execution.succeeded", created: 3, location, data: { sessionID: "ses_v2" } }) + expect(mock.promptCalls).toHaveLength(1) + mock.stream.end() + await cleanup() +}) + +test("V2 fast execution success schedules the next continuation after the minimum interval", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 1 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "do not strand a fast continuation") + await mock.stream.push({ type: "session.execution.succeeded", created: Date.now(), data: { sessionID: "ses_v2" } }) + expect(mock.promptCalls).toHaveLength(1) + const first = (await getGoal("ses_v2"))!.lastContinuationAt! + mock.stream.push({ type: "session.execution.started", created: Date.now(), data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.step.started", created: Date.now(), data: { sessionID: "ses_v2", assistantMessageID: "msg_fast", agent: "build" } }) + mock.stream.push({ type: "session.text.ended", created: Date.now(), data: { sessionID: "ses_v2", assistantMessageID: "msg_fast", text: "A new milestone was verified successfully." } }) + mock.stream.push({ type: "session.step.ended", created: Date.now(), data: { sessionID: "ses_v2", assistantMessageID: "msg_fast", tokens: { output: 100 } } }) + await mock.stream.push({ type: "session.execution.succeeded", created: Date.now(), data: { sessionID: "ses_v2" } }) + await waitFor(() => mock.promptCalls.length === 2) + expect((await getGoal("ses_v2"))!.lastContinuationAt! - first).toBeGreaterThanOrEqual(1) + mock.stream.end() + await cleanup() +}) + test("V2 idle event triggers auto-continue via ctx.session.prompt", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) const cleanup = await setupPlugin(mock as never) @@ -814,6 +865,23 @@ test("V2 idle event triggers auto-continue via ctx.session.prompt", async () => await cleanup() }) +for (const state of ["paused", "plan", "complete", "unmet", "disabled"] as const) { + test(`V2 execution success respects ${state} goals`, async () => { + const mock = makeMockContext({ auto_continue: state !== "disabled", min_continue_interval_seconds: 0 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "preserve goal safety boundaries", state === "plan" ? "plan" : "build") + if (state === "paused") await goalTool(mock, "update_goal_status").execute({ status: "paused" }, toolContext()) + if (state === "complete") await goalTool(mock, "update_goal").execute({ status: "complete", evidence: "verified fixture" }, toolContext()) + if (state === "unmet") await goalTool(mock, "update_goal").execute({ status: "unmet", blocker: "fixture unavailable" }, toolContext()) + mock.stream.push({ type: "session.execution.started", created: 1, data: { sessionID: "ses_v2" } }) + await mock.stream.push({ type: "session.execution.succeeded", created: 2, data: { sessionID: "ses_v2" } }) + mock.stream.end() + await cleanup() + expect(mock.promptCalls).toHaveLength(0) + expect((await getGoal("ses_v2"))?.status).toBe(state === "disabled" ? "active" : state === "plan" ? "paused" : state) + }) +} + test("V2 JSON-encoded idle event triggers auto-continue", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) const cleanup = await setupPlugin(mock as never) @@ -975,6 +1043,78 @@ test("V2 persists the attempt before the prompt resolves so a later busy can cor await cleanup() }) +test("V2 native retry cancels the execution watchdog until execution settles", async () => { + const mock = makeMockContext({ max_turn_time: 0.02, min_continue_interval_seconds: 0 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "do not compete with native provider retries") + mock.stream.push({ type: "session.execution.started", created: 1, data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.retry.scheduled", created: 2, data: { sessionID: "ses_v2", attempt: 2, at: Date.now() + 1000 } }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mock.promptCalls).toHaveLength(0) + expect((await getGoal("ses_v2"))?.continuationFailures).toBe(0) + + mock.stream.push({ type: "session.execution.succeeded", created: 3, data: { sessionID: "ses_v2" } }) + await waitFor(() => mock.promptCalls.length === 1) + mock.stream.end() + await cleanup() +}) + +for (const reason of ["user", "shutdown", "superseded"]) { + test(`V2 execution interruption (${reason}) cancels the watchdog without continuing`, async () => { + const mock = makeMockContext({ max_turn_time: 0.02, min_continue_interval_seconds: 0 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "respect execution interruption") + mock.stream.push({ type: "session.execution.started", created: 1, data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.execution.interrupted", created: 2, data: { sessionID: "ses_v2", reason } }) + // A compatibility idle notification must not undo an explicit interruption. + mock.stream.push({ type: "session.idle", created: 3, data: { sessionID: "ses_v2" } }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mock.promptCalls).toHaveLength(0) + expect((await getGoal("ses_v2"))?.continuationFailures).toBe(0) + + // A new execution (started by OpenCode, not this plugin) can finish normally. + mock.stream.push({ type: "session.execution.started", created: 4, data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.execution.succeeded", created: 5, data: { sessionID: "ses_v2" } }) + await waitFor(() => mock.promptCalls.length === 1) + mock.stream.end() + await cleanup() + }) +} + +test("V2 terminal execution transport failure recovers after native retries and respects the failure ceiling", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0, max_prompt_failures: 1 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "bounded recovery after the host gives up retrying") + mock.stream.push({ type: "session.execution.started", created: 1, data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.retry.scheduled", created: 2, data: { sessionID: "ses_v2", attempt: 2 } }) + mock.stream.push({ type: "session.execution.failed", created: 3, data: { sessionID: "ses_v2", error: { message: "network connection failed" } } }) + await waitFor(async () => (await getGoalInternal("ses_v2"))?.pendingAttempt?.delivered === true) + expect(mock.promptCalls).toHaveLength(1) + expect((await getGoal("ses_v2"))?.continuationFailures).toBe(0) + + mock.stream.push({ type: "session.execution.started", created: 4, data: { sessionID: "ses_v2" } }) + mock.stream.push({ type: "session.execution.failed", created: 5, data: { sessionID: "ses_v2", error: { message: "network connection failed" } } }) + await waitFor(async () => (await getGoal("ses_v2"))?.status === "paused") + expect((await getGoal("ses_v2"))?.continuationFailures).toBe(1) + expect(mock.promptCalls).toHaveLength(1) + mock.stream.end() + await cleanup() +}) + +test("V2 terminal configuration failures stop recovery and compatibility idle continuation", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0, max_turn_time: 0.02 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "do not retry a terminal configuration error") + mock.stream.push({ type: "session.execution.started", created: 1, data: { sessionID: "ses_v2" } }) + await mock.stream.push({ type: "session.execution.failed", created: 2, data: { sessionID: "ses_v2", error: { message: "invalid provider configuration" } } }) + await mock.stream.push({ type: "session.idle", created: 3, data: { sessionID: "ses_v2" } }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mock.promptCalls).toHaveLength(0) + expect((await getGoal("ses_v2"))?.continuationFailures).toBe(0) + mock.stream.end() + await cleanup() +}) + test("V2 retry status cancels scheduled transport recovery", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0 }) const cleanup = await setupPlugin(mock as never)