diff --git a/docs/development.md b/docs/development.md index ba62db5..c46e59a 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. diff --git a/src/platform/opencode/auto-drive-decision.ts b/src/platform/opencode/auto-drive-decision.ts new file mode 100644 index 0000000..31208c7 --- /dev/null +++ b/src/platform/opencode/auto-drive-decision.ts @@ -0,0 +1,129 @@ +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. + * + * 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, + 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 8228e3b..a22e17d 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,98 @@ 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"; + // Narrowing only: decideOnIdle already required hasDelivery. + 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; + // Narrowing only: decideOnIdle already required hasDelivery. + 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)}`); + 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 new file mode 100644 index 0000000..99f3605 --- /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 once the checkpoint has been cleared", () => { + const ready: AutoDriveProjection = { + sessionId: "s1", + status: "ready", + revision: 6, + nextAction: "flow_run_start", + }; + expect(decideOnIdle(lease({ lastPromptedRevision: 5 }), 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" }); + }); +});