From 1d5a1df3172335251b5ab1e80e558062a8bab1f1 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Fri, 18 Sep 2026 09:28:54 +0200 Subject: [PATCH 1/3] refactor(platform): separate auto-drive routing from its side effects decideOnIdle is a pure function over the lease and projection; onIdle applies the chosen action. Behavior is unchanged and the existing tests hold. Co-Authored-By: Claude Fable 5.1 --- src/platform/opencode/auto-drive-decision.ts | 117 +++++++++++ src/platform/opencode/auto-drive.ts | 207 ++++++++----------- tests/auto-drive-decision.test.ts | 174 ++++++++++++++++ 3 files changed, 373 insertions(+), 125 deletions(-) create mode 100644 src/platform/opencode/auto-drive-decision.ts create mode 100644 tests/auto-drive-decision.test.ts diff --git a/src/platform/opencode/auto-drive-decision.ts b/src/platform/opencode/auto-drive-decision.ts new file mode 100644 index 00000000..6b9b2a87 --- /dev/null +++ b/src/platform/opencode/auto-drive-decision.ts @@ -0,0 +1,117 @@ +import type { AutoDriveProjection } from "./auto-drive.js"; + +function isMechanical(projection: AutoDriveProjection): boolean { + return projection.nextAction === "flow_run_start" + ? projection.status === "ready" + : projection.nextAction === "flow_session_close" && + (projection.status === "completed" || projection.status === "closed"); +} +function isCheckpoint(projection: AutoDriveProjection): boolean { + return ["flow_plan_approve", "await-user-direction"].includes( + projection.nextAction ?? "", + ); +} +export function isPendingReviewer(projection: AutoDriveProjection): boolean { + return ( + projection.status === "running" && + projection.nextAction === "dispatch-flow-reviewer" + ); +} +export function isHandback(projection: AutoDriveProjection): boolean { + return ( + projection.status === "blocked" || + projection.nextAction === "flow_feature_reset" || + projection.nextAction === "dispatch-flow-reviewer" + ); +} + +export type LeaseView = Readonly<{ + baseline: AutoDriveProjection; + checkpoint: Readonly<{ + revision: number; + answered: boolean; + advance?: number; + }> | null; + pendingReply: boolean; + lastPromptedRevision: number | null; + hasDelivery: boolean; +}>; + +export type IdleDecision = + | Readonly<{ kind: "deactivate" }> + | Readonly<{ kind: "stop"; warning?: string }> + | Readonly<{ kind: "prompt-initial" }> + | Readonly<{ kind: "handback-and-wait" }> + | Readonly<{ kind: "answered" }> + | Readonly<{ kind: "handback-or-deactivate" }> + | Readonly<{ kind: "pause"; warning: string; clearCheckpoint: boolean }> + | Readonly<{ kind: "continue"; clearCheckpoint: boolean }>; + +/** + * Pure routing for one idle event. Mirrors the branch order of the previous + * inline `onIdle` exactly; the executor applies side effects. + */ +export function decideOnIdle( + lease: LeaseView, + projection: AutoDriveProjection, +): IdleDecision { + const { baseline, checkpoint } = lease; + const anchored = checkpoint !== null; + if (projection.status === "idle") { + if ( + baseline.status !== "idle" || + baseline.sessionId || + lease.lastPromptedRevision === 0 || + !lease.hasDelivery + ) + return { kind: "deactivate" }; + return { kind: "prompt-initial" }; + } + if (projection.nextAction === null) return { kind: "deactivate" }; + if (projection.sessionId !== baseline.sessionId) + return { + kind: "stop", + warning: "Flow auto-drive stopped: unowned session.", + }; + const boundary = isCheckpoint(projection); + const advance = checkpoint?.advance; + const mutationAdvanced = + advance !== undefined && + projection.revision === advance && + isMechanical(projection); + if (lease.pendingReply) { + if (boundary && (!checkpoint || projection.revision > checkpoint.revision)) + return { kind: "handback-and-wait" }; + if (!checkpoint || (!boundary && !mutationAdvanced)) + return { kind: "deactivate" }; + return { kind: "answered" }; + } + if (boundary) { + if (checkpoint && projection.revision < checkpoint.revision) + return { kind: "deactivate" }; + return { kind: "handback-and-wait" }; + } + if (!isMechanical(projection)) return { kind: "handback-or-deactivate" }; + let clearCheckpoint = false; + if (checkpoint) { + if (projection.revision <= checkpoint.revision || !mutationAdvanced) + return { kind: "deactivate" }; + clearCheckpoint = true; + } + if (lease.lastPromptedRevision === projection.revision) + return { + kind: "pause", + warning: `Flow auto-drive paused after revision ${projection.revision} made no lifecycle progress.`, + clearCheckpoint, + }; + if ( + baseline.sessionId + ? projection.revision <= baseline.revision || + (!anchored && !isPendingReviewer(baseline)) + : projection.sessionId === undefined + ) + return { kind: "stop", warning: "Flow auto-drive stopped: no progress." }; + if (!lease.hasDelivery) + return { kind: "stop", warning: "Flow auto-drive stopped: no delivery." }; + return { kind: "continue", clearCheckpoint }; +} diff --git a/src/platform/opencode/auto-drive.ts b/src/platform/opencode/auto-drive.ts index 8228e3b5..f5ab7adc 100644 --- a/src/platform/opencode/auto-drive.ts +++ b/src/platform/opencode/auto-drive.ts @@ -1,4 +1,10 @@ import { FLOW_MANAGER_KERNEL } from "../../guidance/catalog.js"; +import { + decideOnIdle, + type IdleDecision, + isHandback, + isPendingReviewer, +} from "./auto-drive-decision.js"; export const FLOW_AUTO_METADATA_KEY = "opencode-plugin-flow/auto"; export interface AutoDriveProjection { readonly sessionId?: string | undefined; @@ -122,30 +128,6 @@ function inspectMessage(parts: readonly AutoDriveMessagePart[]) { } return { token, user, text: text.trim().replace(/\s+/g, " ") }; } -function isMechanical(projection: AutoDriveProjection): boolean { - return projection.nextAction === "flow_run_start" - ? projection.status === "ready" - : projection.nextAction === "flow_session_close" && - (projection.status === "completed" || projection.status === "closed"); -} -function isCheckpoint(projection: AutoDriveProjection): boolean { - return ["flow_plan_approve", "await-user-direction"].includes( - projection.nextAction ?? "", - ); -} -function isPendingReviewer(projection: AutoDriveProjection): boolean { - return ( - projection.status === "running" && - projection.nextAction === "dispatch-flow-reviewer" - ); -} -function isHandback(projection: AutoDriveProjection): boolean { - return ( - projection.status === "blocked" || - projection.nextAction === "flow_feature_reset" || - projection.nextAction === "dispatch-flow-reviewer" - ); -} export class AutoDriveCoordinator { #lease: Lease | null = null; #timing: Timing | null = null; @@ -453,115 +435,90 @@ export class AutoDriveCoordinator { if (this.#lease !== lease) return; const baseline = lease.baseline; if (!baseline) return this.#stop(lease); - const anchored = lease.checkpoint !== null; - if (projection.status === "idle") { - if ( - baseline.status !== "idle" || - baseline.sessionId || - lease.lastPromptedRevision === 0 || - !lease.delivery - ) - return void this.deactivate(hostSessionId); - lease.lastPromptedRevision = 0; - lease.inFlight = "prompt"; - await this.#options - .prompt( - hostSessionId, - `${INITIAL_ROUTE}\n\n${FLOW_MANAGER_KERNEL}`, - lease.delivery, - { [FLOW_AUTO_METADATA_KEY]: lease.token }, - ) - .catch((error) => - this.#stop(lease, `Flow auto prompt failed: ${String(error)}`), - ); - return; - } - if (projection.nextAction === null) - return void this.deactivate(hostSessionId); - if (projection.sessionId !== baseline.sessionId) - return this.#stop(lease, "Flow auto-drive stopped: unowned session."); - const checkpoint = lease.checkpoint; - const boundary = isCheckpoint(projection); - const advance = checkpoint?.advance; - const mutationAdvanced = - advance !== undefined && - projection.revision === advance && - isMechanical(projection); - if (lease.pendingReply) { + const decision: IdleDecision = decideOnIdle( + { + baseline, + checkpoint: lease.checkpoint, + pendingReply: lease.pendingReply, + lastPromptedRevision: lease.lastPromptedRevision, + hasDelivery: lease.delivery !== null, + }, + projection, + ); + if (lease.pendingReply && projection.status !== "idle") lease.pendingReply = false; - if ( - boundary && - (!checkpoint || projection.revision > checkpoint.revision) - ) { + switch (decision.kind) { + case "deactivate": + return void this.deactivate(hostSessionId); + case "stop": + return this.#stop(lease, decision.warning); + case "prompt-initial": { + lease.lastPromptedRevision = 0; + lease.inFlight = "prompt"; + if (!lease.delivery) return; + await this.#options + .prompt( + hostSessionId, + `${INITIAL_ROUTE}\n\n${FLOW_MANAGER_KERNEL}`, + lease.delivery, + { [FLOW_AUTO_METADATA_KEY]: lease.token }, + ) + .catch((error) => + this.#stop(lease, `Flow auto prompt failed: ${String(error)}`), + ); + return; + } + case "handback-and-wait": await this.#promptHandback(lease, projection); if (this.#lease !== lease) return; return void this.#waitAt(lease, projection.revision); - } - if (!checkpoint || (!boundary && !mutationAdvanced)) - return void this.deactivate(hostSessionId); - checkpoint.answered = true; - this.#setTiming("active"); - return; - } - if (boundary) { - if (checkpoint && projection.revision < checkpoint.revision) + case "answered": + if (lease.checkpoint) lease.checkpoint.answered = true; + this.#setTiming("active"); + return; + case "handback-or-deactivate": { + const already = + lease.handbackPromptedRevision === projection.revision; + await this.#promptHandback(lease, projection); + if (this.#lease !== lease) return; + if ( + !already && + lease.handbackPromptedRevision === projection.revision + ) { + this.#setTiming("paused"); + return; + } return void this.deactivate(hostSessionId); - await this.#promptHandback(lease, projection); - if (this.#lease !== lease) return; - return void this.#waitAt(lease, projection.revision); - } - if (!isMechanical(projection)) { - const already = lease.handbackPromptedRevision === projection.revision; - await this.#promptHandback(lease, projection); - if (this.#lease !== lease) return; - if ( - !already && - lease.handbackPromptedRevision === projection.revision - ) { + } + case "pause": + if (decision.clearCheckpoint) lease.checkpoint = null; this.#setTiming("paused"); + return this.#warn(decision.warning); + case "continue": { + if (decision.clearCheckpoint) lease.checkpoint = null; + if (!lease.delivery) return; + lease.lastPromptedRevision = projection.revision; + lease.messageId = null; + this.#setTiming("active"); + lease.inFlight = "prompt"; + try { + const continuation = [ + `Continue the same user-authorized /flow-auto lifecycle from compact revision ${projection.revision}.`, + "Call flow_status with the compact view first.", + CONTINUATION_ROUTE, + `Then follow ${projection.nextAction} without expanding the approved goal.`, + ].join(" "); + await this.#options.prompt( + hostSessionId, + `${continuation}\n\n${FLOW_MANAGER_KERNEL}`, + lease.delivery, + { [FLOW_AUTO_METADATA_KEY]: lease.token }, + ); + } catch (error) { + this.#stop(lease, `Flow auto prompt failed: ${String(error)}`); + } return; } - return void this.deactivate(hostSessionId); - } - if (checkpoint) { - if (projection.revision <= checkpoint.revision || !mutationAdvanced) - return void this.deactivate(hostSessionId); - lease.checkpoint = null; - } - if (lease.lastPromptedRevision === projection.revision) { - this.#setTiming("paused"); - return this.#warn( - `Flow auto-drive paused after revision ${projection.revision} made no lifecycle progress.`, - ); - } - if ( - baseline.sessionId - ? projection.revision <= baseline.revision || - (!anchored && !isPendingReviewer(baseline)) - : projection.sessionId === undefined - ) - return this.#stop(lease, "Flow auto-drive stopped: no progress."); - if (!lease.delivery) - return this.#stop(lease, "Flow auto-drive stopped: no delivery."); - lease.lastPromptedRevision = projection.revision; - lease.messageId = null; - this.#setTiming("active"); - lease.inFlight = "prompt"; - try { - const continuation = [ - `Continue the same user-authorized /flow-auto lifecycle from compact revision ${projection.revision}.`, - "Call flow_status with the compact view first.", - CONTINUATION_ROUTE, - `Then follow ${projection.nextAction} without expanding the approved goal.`, - ].join(" "); - await this.#options.prompt( - hostSessionId, - `${continuation}\n\n${FLOW_MANAGER_KERNEL}`, - lease.delivery, - { [FLOW_AUTO_METADATA_KEY]: lease.token }, - ); - } catch (error) { - this.#stop(lease, `Flow auto prompt failed: ${String(error)}`); } } finally { if (this.#lease === lease) { diff --git a/tests/auto-drive-decision.test.ts b/tests/auto-drive-decision.test.ts new file mode 100644 index 00000000..0d4ec34a --- /dev/null +++ b/tests/auto-drive-decision.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import type { AutoDriveProjection } from "../src/platform/opencode/auto-drive.js"; +import { + decideOnIdle, + type LeaseView, +} from "../src/platform/opencode/auto-drive-decision.js"; + +const running: AutoDriveProjection = { + sessionId: "s1", + status: "running", + revision: 5, + nextAction: "flow_validation_start", +}; + +function lease(overrides: Partial = {}): LeaseView { + return { + baseline: { + sessionId: "s1", + status: "ready", + revision: 3, + nextAction: "flow_run_start", + }, + checkpoint: null, + pendingReply: false, + lastPromptedRevision: null, + hasDelivery: true, + ...overrides, + }; +} + +describe("decideOnIdle", () => { + test("prompts the initial route from an idle workspace once", () => { + const idle: AutoDriveProjection = { + status: "idle", + revision: 0, + nextAction: "flow_plan_save", + }; + const fresh = lease({ + baseline: { status: "idle", revision: 0, nextAction: "flow_plan_save" }, + }); + expect(decideOnIdle(fresh, idle)).toEqual({ kind: "prompt-initial" }); + expect(decideOnIdle({ ...fresh, lastPromptedRevision: 0 }, idle)).toEqual({ + kind: "deactivate", + }); + }); + + test("deactivates when the projection has no next action", () => { + expect(decideOnIdle(lease(), { ...running, nextAction: null })).toEqual({ + kind: "deactivate", + }); + }); + + test("stops on an unowned session", () => { + expect(decideOnIdle(lease(), { ...running, sessionId: "other" })).toEqual({ + kind: "stop", + warning: "Flow auto-drive stopped: unowned session.", + }); + }); + + test("hands back and waits at a checkpoint boundary", () => { + const approve: AutoDriveProjection = { + ...running, + status: "planning", + nextAction: "flow_plan_approve", + }; + expect(decideOnIdle(lease(), approve)).toEqual({ + kind: "handback-and-wait", + }); + }); + + test("deactivates when a boundary appears below the recorded checkpoint", () => { + const approve: AutoDriveProjection = { + ...running, + revision: 2, + status: "planning", + nextAction: "flow_plan_approve", + }; + expect( + decideOnIdle( + lease({ checkpoint: { revision: 4, answered: false } }), + approve, + ), + ).toEqual({ kind: "deactivate" }); + }); + + test("treats a non-mechanical projection as handback-or-deactivate", () => { + expect(decideOnIdle(lease(), running)).toEqual({ + kind: "handback-or-deactivate", + }); + }); + + test("continues on a mechanical advance past an answered checkpoint", () => { + const ready: AutoDriveProjection = { + sessionId: "s1", + status: "ready", + revision: 6, + nextAction: "flow_run_start", + }; + const view = lease({ + checkpoint: { revision: 4, answered: true, advance: 6 }, + }); + expect(decideOnIdle(view, ready)).toEqual({ + kind: "continue", + clearCheckpoint: true, + }); + }); + + test("pauses when the same revision was already prompted", () => { + const ready: AutoDriveProjection = { + sessionId: "s1", + status: "ready", + revision: 6, + nextAction: "flow_run_start", + }; + expect(decideOnIdle(lease({ lastPromptedRevision: 6 }), ready)).toEqual({ + kind: "pause", + warning: + "Flow auto-drive paused after revision 6 made no lifecycle progress.", + clearCheckpoint: false, + }); + }); + + test("stops without delivery once a checkpoint has been passed", () => { + // Needs an anchored checkpoint with a matching advance: without one the + // "no progress" rule fires first, and without the advance the checkpoint + // rule deactivates. Only then does the delivery check become reachable. + const ready: AutoDriveProjection = { + sessionId: "s1", + status: "ready", + revision: 6, + nextAction: "flow_run_start", + }; + const view = lease({ + hasDelivery: false, + checkpoint: { revision: 3, answered: true, advance: 6 }, + }); + expect(decideOnIdle(view, ready)).toEqual({ + kind: "stop", + warning: "Flow auto-drive stopped: no delivery.", + }); + }); + + test("stops on no progress when the lease was never anchored", () => { + const ready: AutoDriveProjection = { + sessionId: "s1", + status: "ready", + revision: 6, + nextAction: "flow_run_start", + }; + expect(decideOnIdle(lease(), ready)).toEqual({ + kind: "stop", + warning: "Flow auto-drive stopped: no progress.", + }); + }); + + test("marks a pending reply as answered when nothing moved", () => { + const ready: AutoDriveProjection = { + sessionId: "s1", + status: "ready", + revision: 4, + nextAction: "flow_run_start", + }; + const view = lease({ + pendingReply: true, + checkpoint: { revision: 4, answered: false }, + }); + expect(decideOnIdle(view, ready)).toEqual({ kind: "deactivate" }); + const advanced = lease({ + pendingReply: true, + checkpoint: { revision: 3, answered: false, advance: 4 }, + }); + expect(decideOnIdle(advanced, ready)).toEqual({ kind: "answered" }); + }); +}); From c7d8cb2e0ca65bb71c7c1b7f8c0ab3a6a9d863b7 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Fri, 18 Sep 2026 09:39:16 +0200 Subject: [PATCH 2/3] docs(development): name the auto-drive decision module and restore trimmed qualifiers Co-Authored-By: Claude Fable 5.1 --- docs/development.md | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/development.md b/docs/development.md index ba62db59..c46e59a4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,7 +24,7 @@ bun run package:smoke ``` The opt-in real-host check launches the pinned `opencode-ai` package through -`bunx`. It requires registry access or a populated Bun cache rather than a +`bunx`. It requires registry access or a populated Bun cache, not a separately installed OpenCode binary: ```bash @@ -37,7 +37,7 @@ bun run smoke:live so they are raised by hand. The plugin pin is also the host version `smoke:live` launches, so bumping it puts a new host under test: -1. Move the `devDependencies` pin, and `peerDependencies` too if the new version +1. Move the `devDependencies` pin, and `peerDependencies` if the new version falls outside the declared range. 2. Run `bun run check`, then `bun run smoke:live` for the real host. @@ -56,23 +56,24 @@ compatibility claim no check has run. - `src/platform/opencode/` owns OpenCode hooks, host schemas, commands, tools, validation capture, and the duplicate-runtime guard: `command-hook.ts` (slash-command hook), `tool-guard.ts` (leadership and auto-drive guard - around the tools), `plugin.ts` (wiring only). + around the tools), `auto-drive-decision.ts` (pure idle-routing decision, + `decideOnIdle`, run by `auto-drive.ts`), `plugin.ts` (wiring only). - `src/guidance/`, `skills/`, and prompt surfaces own concise workflow judgment. - `tests/` prove state-machine, persistence, platform, package, and host contracts. Dependencies point inward. Domain code does not import filesystem or host APIs; application code depends on domain; infrastructure implements application -ports; the OpenCode platform composes the outer layers. +ports; the OpenCode platform composes outer layers. There is no distribution/activation subsystem, cache inventory, repair journal, or Flow-owned installer: OpenCode installs and loads the npm package -from its native plugin command and normal configuration. +from its native plugin command and configuration. ## Change discipline -- Keep Session v5 as one canonical run aggregate: derive status and progress - instead of parallel ledgers or cached counters. +- Keep Session v5 as one canonical run aggregate: derive status and progress, + not parallel ledgers or cached counters. - Every mutation needs a revision guard and stable operation ID. Exact replay is safe; conflicting reuse fails. - Only the reserved reviewer may create a new completion; while the Session v5 @@ -84,7 +85,8 @@ from its native plugin command and normal configuration. - Treat validation scope as a coverage claim: `broad` means the canonical repository gate, byte for byte, not a narrow command relabeled. - Validation commands are persisted, never with inline secrets. Raw output is - reduced to completeness and a digest rather than stored or projected. + intentionally reduced to completeness and a digest rather than stored or + projected. - Keep one review per run. A final review requires broad validation and is not a second pass. The reviewer submits through `flow_feature_complete`; the manager never proxies its verdict. @@ -96,14 +98,14 @@ from its native plugin command and normal configuration. ## Documentation Update the README, maintainer contract, ADR, and changelog when a public -lifecycle or installation contract changes; documentation describes only the -current product, and Git history owns superseded plans and experiments. +lifecycle or installation contract changes. Documentation must describe only +the current product; Git history owns superseded plans and experiments. ## Model-driven wave evidence Deterministic CI validates schemas, permissions, prompts, and host integration without provider credentials. It does not claim a model overlaps workers, so -changes to wave behavior should be exercised manually with a real provider +wave-behavior changes need manual exercise with a real provider when available, with sanitized evidence of: - worker start/end times with a positive common overlap; @@ -115,13 +117,13 @@ Every wave-behavior change needs this evidence when marked verified, but it is not a deterministic release gate. Without a provider, mark the behavior unverified, record the review risk, and avoid performance or reliability claims. Do not persist prompts, secrets, raw provider payloads, or a -wave ledger, and do not add provider credentials, a scheduler, or telemetry to +wave ledger, or add provider credentials, a scheduler, or telemetry to CI. ## Model-driven auto-continuation evidence Deterministic tests exercise the coordinator through the real plugin hooks and -the `promptAsync` client boundary, but do not prove how a configured model +the `promptAsync` client boundary, but not how a configured model behaves after delivery. When auto-continuation behavior changes and a provider is available, run one packed-plugin canary recording sanitized evidence of: @@ -159,14 +161,14 @@ cross-version active-session gate; v6 is an explicit hard cutover. Publication accepts both annotated and lightweight tags, but the freshly fetched tag, workflow event, checkout, and current remote `main` tip must identify the -same commit before npm publication. Network calls have explicit deadlines. npm -publication reconciles the immutable package integrity after every result, -including timeouts. GitHub publication first builds an exact draft under the -same ref proof. That draft recovers if npm succeeds and `main` then advances. -Finalization rechecks the remote tag, refuses conflicting metadata -or assets, and publishes only after every asset digest matches. Reruns converge -after partial success without replacing published bytes. +same commit immediately before npm publication. Network calls have explicit +deadlines. npm publication reconciles the immutable package integrity after +every result, including timeouts. GitHub publication builds an exact draft under +the same ref proof. That draft recovers if npm succeeds and `main` advances. +Finalization rechecks the remote tag, refuses conflicting metadata or assets, +and publishes only after every asset digest matches. Reruns converge after +partial success without replacing published bytes. Preparing an already-published release is read-only and requires exact assets. Missing or pending assets fail preparation; use the `github-publish` recovery -path with the original inputs and tag proof to restore a missing asset. +path with original inputs and tag proof to restore a missing asset. From 6ca8a484d631111b39098f85561d5c03bd576e95 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Fri, 18 Sep 2026 09:52:52 +0200 Subject: [PATCH 3/3] refactor(platform): guard the auto-drive executor against unhandled decisions The executor switch now fails to compile on a new decision kind, stop decisions always carry their warning, the two narrowing guards say why they exist, the decision function documents its evaluation order, and one decision test is renamed for what it proves. Co-Authored-By: Claude Fable 5.1 --- src/platform/opencode/auto-drive-decision.ts | 14 +++++++++++++- src/platform/opencode/auto-drive.ts | 8 ++++++++ tests/auto-drive-decision.test.ts | 4 ++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/platform/opencode/auto-drive-decision.ts b/src/platform/opencode/auto-drive-decision.ts index 6b9b2a87..31208c79 100644 --- a/src/platform/opencode/auto-drive-decision.ts +++ b/src/platform/opencode/auto-drive-decision.ts @@ -39,7 +39,7 @@ export type LeaseView = Readonly<{ export type IdleDecision = | Readonly<{ kind: "deactivate" }> - | Readonly<{ kind: "stop"; warning?: string }> + | Readonly<{ kind: "stop"; warning: string }> | Readonly<{ kind: "prompt-initial" }> | Readonly<{ kind: "handback-and-wait" }> | Readonly<{ kind: "answered" }> @@ -50,6 +50,18 @@ export type IdleDecision = /** * Pure routing for one idle event. Mirrors the branch order of the previous * inline `onIdle` exactly; the executor applies side effects. + * + * Evaluation order (first match wins): + * 1. idle workspace → deactivate | prompt-initial + * 2. no next action → deactivate + * 3. unowned session → stop + * 4. pending reply → handback-and-wait | deactivate | answered + * 5. checkpoint boundary → deactivate | handback-and-wait + * 6. non-mechanical action → handback-or-deactivate + * 7. stale/unadvanced checkpoint → deactivate (else clear it) + * 8. same revision re-prompted → pause + * 9. no progress / no delivery → stop + * 10. otherwise → continue */ export function decideOnIdle( lease: LeaseView, diff --git a/src/platform/opencode/auto-drive.ts b/src/platform/opencode/auto-drive.ts index f5ab7adc..a22e17d3 100644 --- a/src/platform/opencode/auto-drive.ts +++ b/src/platform/opencode/auto-drive.ts @@ -455,6 +455,7 @@ export class AutoDriveCoordinator { case "prompt-initial": { lease.lastPromptedRevision = 0; lease.inFlight = "prompt"; + // Narrowing only: decideOnIdle already required hasDelivery. if (!lease.delivery) return; await this.#options .prompt( @@ -496,6 +497,7 @@ export class AutoDriveCoordinator { return this.#warn(decision.warning); case "continue": { if (decision.clearCheckpoint) lease.checkpoint = null; + // Narrowing only: decideOnIdle already required hasDelivery. if (!lease.delivery) return; lease.lastPromptedRevision = projection.revision; lease.messageId = null; @@ -519,6 +521,12 @@ export class AutoDriveCoordinator { } return; } + default: { + const unhandled: never = decision; + throw new Error( + `Unhandled auto-drive decision: ${String(unhandled)}`, + ); + } } } finally { if (this.#lease === lease) { diff --git a/tests/auto-drive-decision.test.ts b/tests/auto-drive-decision.test.ts index 0d4ec34a..99f36050 100644 --- a/tests/auto-drive-decision.test.ts +++ b/tests/auto-drive-decision.test.ts @@ -140,14 +140,14 @@ describe("decideOnIdle", () => { }); }); - test("stops on no progress when the lease was never anchored", () => { + test("stops on no progress once the checkpoint has been cleared", () => { const ready: AutoDriveProjection = { sessionId: "s1", status: "ready", revision: 6, nextAction: "flow_run_start", }; - expect(decideOnIdle(lease(), ready)).toEqual({ + expect(decideOnIdle(lease({ lastPromptedRevision: 5 }), ready)).toEqual({ kind: "stop", warning: "Flow auto-drive stopped: no progress.", });