From f94f501c807effd36797ae9b91c9c1b2385c9b48 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 14:47:15 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A7=AA=20Prove=20black-box=20terminal?= =?UTF-8?q?=20REPL=20messaging=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disposable POC under packages/terminal-tmux/poc/repl/: a Flux-style action store, strict read-only Claude/Codex session-file observers, a generic terminal-convergence algorithm, literal tmux paste delivery, and a schema-validated report. Deterministic RP1-RP18 evidence runs against fake panes and synthetic session files in packages/terminal-tmux/tests/repl-poc.test.ts. The live journey is doubly gated per provider and unexercised until authorized. No production src, package export, CLI grammar, terminal/core/ACP API, Workflow, architecture, specification, dependency or lockfile changed. --- .../poc/repl/ClaudeBlackBoxRepl.test.md | 111 ++ .../poc/repl/CodexBlackBoxRepl.test.md | 112 ++ packages/terminal-tmux/poc/repl/README.md | 71 ++ .../poc/repl/TerminalReplLive.md | 33 + packages/terminal-tmux/poc/repl/actions.ts | 200 ++++ .../terminal-tmux/poc/repl/claude-observer.ts | 93 ++ .../terminal-tmux/poc/repl/codex-observer.ts | 85 ++ packages/terminal-tmux/poc/repl/controller.ts | 340 ++++++ .../terminal-tmux/poc/repl/convergence.ts | 144 +++ packages/terminal-tmux/poc/repl/delivery.ts | 91 ++ .../terminal-tmux/poc/repl/live-supervisor.ts | 133 +++ .../terminal-tmux/poc/repl/live-worker.ts | 642 ++++++++++ packages/terminal-tmux/poc/repl/observer.ts | 313 +++++ .../terminal-tmux/poc/repl/report.schema.json | 156 +++ packages/terminal-tmux/poc/repl/report.ts | 170 +++ packages/terminal-tmux/poc/repl/state.ts | 282 +++++ packages/terminal-tmux/poc/repl/store.ts | 162 +++ .../tests/fixtures/repl-poc/fake-terminal.ts | 310 +++++ .../fixtures/repl-poc/synthetic-claude.jsonl | 4 + .../fixtures/repl-poc/synthetic-codex.jsonl | 4 + packages/terminal-tmux/tests/repl-poc.test.ts | 1031 +++++++++++++++++ 21 files changed, 4487 insertions(+) create mode 100644 packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md create mode 100644 packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md create mode 100644 packages/terminal-tmux/poc/repl/README.md create mode 100644 packages/terminal-tmux/poc/repl/TerminalReplLive.md create mode 100644 packages/terminal-tmux/poc/repl/actions.ts create mode 100644 packages/terminal-tmux/poc/repl/claude-observer.ts create mode 100644 packages/terminal-tmux/poc/repl/codex-observer.ts create mode 100644 packages/terminal-tmux/poc/repl/controller.ts create mode 100644 packages/terminal-tmux/poc/repl/convergence.ts create mode 100644 packages/terminal-tmux/poc/repl/delivery.ts create mode 100644 packages/terminal-tmux/poc/repl/live-supervisor.ts create mode 100644 packages/terminal-tmux/poc/repl/live-worker.ts create mode 100644 packages/terminal-tmux/poc/repl/observer.ts create mode 100644 packages/terminal-tmux/poc/repl/report.schema.json create mode 100644 packages/terminal-tmux/poc/repl/report.ts create mode 100644 packages/terminal-tmux/poc/repl/state.ts create mode 100644 packages/terminal-tmux/poc/repl/store.ts create mode 100644 packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts create mode 100644 packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-claude.jsonl create mode 100644 packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-codex.jsonl create mode 100644 packages/terminal-tmux/tests/repl-poc.test.ts diff --git a/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md b/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md new file mode 100644 index 000000000..e3ac5a0b2 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md @@ -0,0 +1,111 @@ +# Black-box REPL messaging to a native Claude pane + +`xmd run` can open a Claude coding agent in a terminal pane. This document asks +the one question the POC exists to answer for Claude: **can generic terminal-state +convergence deliver one literal REPL message into that pane, and can Claude's own +session file confirm the exact message was accepted and answered — without any +hook, plugin, or screen scraping?** + +The journey launches the real grid, waits for the pane's terminal to converge on a +safe input point, pastes one uniquely-marked message as literal bytes, and then +reads Claude's own session file for the exact user event and its completion. The +terminal only ever authorizes the attempt; acceptance is the provider file's word. + +## What this costs, and what it touches + +One Claude model turn against the operator's own credentials. It runs under a +private `HOME` and `TMPDIR`, so `.xmd`, `.acpx`, the adapters, the launch journal, +the tmux server and the POC store are isolated. `CLAUDE_CONFIG_DIR` is left as the +operator has it so Claude stays authenticated; nothing beneath Claude's +configuration or history is swept. Claude's project state for the temporary +directory is cleaned afterward through Claude's own `project purge`. + +It is gated twice over. Without `XMD_TERMINAL_REPL_CLAUDE_PROOF=1` and +`XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1`, the supervisor refuses before +any agent starts and before any transcript is opened, printing a `NOT_AUTHORIZED` +report that spent nothing. That is the branch that runs on an ordinary machine and +in CI. + +Run it, once authorized, with: + +```sh +XMD_TERMINAL_REPL_CLAUDE_PROOF=1 XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1 \ + deno task xmd test packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md --raw +``` + +## What a verdict may say + +The report carries versions, hashes, counters, turn budgets and cleanup outcomes, +and no conversation: no transcript, no reply, no path, no argv, no environment, no +tmux identifier, and no raw native identity. + + + +```json +{ + "type": "object", + "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], + "properties": { + "schema": { "const": "terminal-repl-poc-report.v1" }, + "verdict": { + "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] + }, + "mode": { "type": "string" }, + "detail": { "type": "string" }, + "turnBudgets": { "type": "object" }, + "counters": { "type": "object" } + } +} +``` + + + + + +```sh timeout=25min exec as="run" +deno run --allow-all packages/terminal-tmux/poc/repl/live-supervisor.ts claude +``` + +The supervisor returns a structured report and exits zero even when it refuses, so +a nonzero exit means the supervisor itself broke rather than a question being +answered. + + + + +{run.stdout} + + +The whole report is shown before anything is judged. + +```json +{run.stdout} +``` + + + + +The exact marked message was accepted under the intended native identity and the +turn completed, observed from Claude's own session file and never from the screen. + + + + + + + + +The ordinary path: without both gates nothing started and no turn was spent. This +is the pass on a developer machine and in CI. + + + + + + + + + + + + diff --git a/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md b/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md new file mode 100644 index 000000000..da06841c5 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md @@ -0,0 +1,112 @@ +# Black-box REPL messaging to a native Codex pane + +`xmd run` can open a Codex coding agent in a terminal pane. This document asks the +one question the POC exists to answer for Codex: **can generic terminal-state +convergence deliver one literal REPL message into that pane, and can Codex's own +rollout file confirm the exact message was accepted and answered — without any +hook, plugin, or screen scraping?** + +The journey launches the real grid, waits for the pane's terminal to converge on a +safe input point, pastes one uniquely-marked message as literal bytes, and then +reads Codex's own rollout file for the exact user event and its completion. The +terminal only ever authorizes the attempt; acceptance is the provider file's word. + +## What this costs, and what it touches + +At most two Codex model turns against the operator's own credentials: one if the +thread is already resumable, otherwise one materialization turn and one marker +turn. It runs under a private `HOME` and `TMPDIR`, so `.xmd`, `.acpx`, the +adapters, the launch journal, the tmux server and the POC store are isolated. +`CODEX_HOME` is left as the operator has it so Codex stays authenticated; nothing +beneath it is swept. The conversation this run creates is removed afterward through +Codex's own `delete`. + +It is gated twice over. Without `XMD_TERMINAL_REPL_CODEX_PROOF=1` and +`XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2`, the supervisor refuses before +any agent starts and before any transcript is opened, printing a `NOT_AUTHORIZED` +report that spent nothing. That is the branch that runs on an ordinary machine and +in CI. + +Run it, once authorized, with: + +```sh +XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 \ + deno task xmd test packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md --raw +``` + +## What a verdict may say + +The report carries versions, hashes, counters, turn budgets and cleanup outcomes, +and no conversation: no transcript, no reply, no path, no argv, no environment, no +tmux identifier, and no raw native identity. + + + +```json +{ + "type": "object", + "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], + "properties": { + "schema": { "const": "terminal-repl-poc-report.v1" }, + "verdict": { + "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] + }, + "mode": { "type": "string" }, + "detail": { "type": "string" }, + "turnBudgets": { "type": "object" }, + "counters": { "type": "object" } + } +} +``` + + + + + +```sh timeout=25min exec as="run" +deno run --allow-all packages/terminal-tmux/poc/repl/live-supervisor.ts codex +``` + +The supervisor returns a structured report and exits zero even when it refuses, so +a nonzero exit means the supervisor itself broke rather than a question being +answered. + + + + +{run.stdout} + + +The whole report is shown before anything is judged. + +```json +{run.stdout} +``` + + + + +The exact marked message was accepted under the intended native identity and the +turn completed, observed from Codex's own rollout file and never from the screen. + + + + + + + + +The ordinary path: without both gates nothing started and no turn was spent. This +is the pass on a developer machine and in CI. + + + + + + + + + + + + diff --git a/packages/terminal-tmux/poc/repl/README.md b/packages/terminal-tmux/poc/repl/README.md new file mode 100644 index 000000000..367aa13c8 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/README.md @@ -0,0 +1,71 @@ +# Black-box terminal REPL POC (issue #774) + +A finite, disposable proof — not the production REPL, and not part of any package +export. It answers one question: + +> Can generic terminal-state convergence make black-box tmux input delivery +> reliable enough while provider session files supply authoritative acceptance and +> completion? + +Nothing here is exported from `@executablemd/terminal-tmux`. It is reached only by +the deterministic evidence in `packages/terminal-tmux/tests/repl-poc.test.ts` and, +under explicit authorization, by the live supervisor here. It adds no Workflow +capability, no journal record, no replay rule, and no workflow syntax. + +## The pieces + +- `state.ts`, `actions.ts` — one Flux-style immutable state and its closed action + vocabulary. Actions are the only way state changes; the reducer is the only place + a transition is written. +- `store.ts` — a sequence-numbered, staged-write action log. Restart replays it and + refuses a gap, a duplicate or a malformed record. +- `observer.ts` with `claude-observer.ts` and `codex-observer.ts` — a strict, + read-only provider session-file boundary. It matches only the exact native + identity, advances the cursor only past a complete record, and refuses on + ambiguity, truncation, rotation, identity mismatch or an unsupported shape. It + never writes to a provider file. +- `convergence.ts` — the generic terminal-convergence algorithm. No prompt or + screen-text parsing: two structurally equal snapshots across an acknowledged + barrier, no intervening event, and no open provider turn. +- `delivery.ts` — literal delivery through a private `0600` file and a uniquely + named tmux buffer, with a separate submit key. Message bytes never enter a shell + or a tmux argument vector. +- `controller.ts` — the message lifecycle: converge, record intent durably, paste + under a final guard, then confirm acceptance from the provider file. An unproved + outcome becomes uncertain and is never pasted again. +- `report.ts` with `report.schema.json` — the `terminal-repl-poc-report.v1` + artifact and its validator. It carries hashes, counters, versions, turn budgets, + the RP matrix and restart/cleanup evidence, and no conversation content. +- `live-supervisor.ts`, `live-worker.ts`, `TerminalReplLive.md`, + `ClaudeBlackBoxRepl.test.md`, `CodexBlackBoxRepl.test.md` — the gated live + journey. It refuses before starting any agent or opening any transcript unless + both of its exact gates are supplied. + +## The deterministic matrix + +`packages/terminal-tmux/tests/repl-poc.test.ts` freezes RP1–RP18 and runs them +against fake panes and synthetic append-only session files. The fake pane exposes +hidden busy and manual ground truth only to the assertions, never to the +algorithm, so a paste admitted while the pane was busy or a person was typing is +caught. Run it with: + +```sh +deno task test packages/terminal-tmux/tests/repl-poc.test.ts +``` + +## The live journey + +The live journey never runs in ordinary CI and spends real model turns. It is gated +twice over per provider, and previous authorization does not count: + +```sh +XMD_TERMINAL_REPL_CLAUDE_PROOF=1 XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1 \ + deno task xmd test packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md --raw + +XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 \ + deno task xmd test packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md --raw +``` + +Without both exact values the supervisor prints a `NOT_AUTHORIZED` report and starts +nothing. The live journey body in `live-worker.ts` is unexercised until an +authorized run, which is the only context allowed to spend the turns it needs. diff --git a/packages/terminal-tmux/poc/repl/TerminalReplLive.md b/packages/terminal-tmux/poc/repl/TerminalReplLive.md new file mode 100644 index 000000000..5393a96eb --- /dev/null +++ b/packages/terminal-tmux/poc/repl/TerminalReplLive.md @@ -0,0 +1,33 @@ +# Terminal REPL live grid + +This document is what an authorized live journey launches. It opens one `xmd run` +terminal grid with two black-box coding agents — a Claude pane and a Codex pane — +each in its own native interface. The REPL POC never modifies these agents; it +watches their session files and delivers one literal message to a pane once that +pane's terminal state has converged. + +It is launched only by the POC's live supervisor, under a private `HOME` and +`TMPDIR`, so the grid's tmux server, the launch journal, and the POC store are all +isolated from the operator's own work. The panes' logical session names are +stable; isolation comes from the private temporary project directory the +supervisor runs this in, whose path already makes each session key unique. + + + + + +You are the Implementor pane in a black-box REPL messaging check. Wait for a +message. When one arrives, follow it exactly and reply on a single line. + + + + + + + +You are the Reviewer pane in a black-box REPL messaging check. Wait for a +message. When one arrives, follow it exactly and reply on a single line. + + + + diff --git a/packages/terminal-tmux/poc/repl/actions.ts b/packages/terminal-tmux/poc/repl/actions.ts new file mode 100644 index 000000000..5a95d56ee --- /dev/null +++ b/packages/terminal-tmux/poc/repl/actions.ts @@ -0,0 +1,200 @@ +/** + * Issue #774 POC — the fixed action vocabulary. + * + * Actions are the only way the REPL store changes. The vocabulary is closed: + * every transition the REPL, the delivery worker and the two session-file + * observers can cause is one of the shapes below, and the reducer in + * `state.ts` is the only place they are applied. + * + * Each action is a plain value with a `type` discriminant. The store stamps a + * monotonic sequence number onto every one it accepts and persists it under + * that number, so the retained history is an ordered, gap-free log that replays + * to the exact state the run held. + */ + +import type { NativeIdentity, Readiness } from "./state.ts"; + +/** The one action that has no role: opening the REPL session itself. */ +export interface ReplOpened { + readonly type: "ReplOpened"; + readonly replSession: string; +} + +/** Bind one authored role to its native identity and initial pane generation. */ +export interface RoleBound { + readonly type: "RoleBound"; + readonly key: string; + readonly role: string; + readonly issue: string; + readonly identity: NativeIdentity; + readonly paneGeneration: number; +} + +/** A message the operator asked to send, queued behind any earlier work. */ +export interface MessageQueued { + readonly type: "MessageQueued"; + readonly key: string; + readonly id: string; + readonly text: string; + readonly marker: string; +} + +/** Terminal convergence reported a readiness for a role's pane. */ +export interface TerminalObserved { + readonly type: "TerminalObserved"; + readonly key: string; + readonly readiness: Readiness; +} + +/** The provider observer reports an open turn: the pane must not be written to. */ +export interface ProviderBusy { + readonly type: "ProviderBusy"; + readonly key: string; +} + +/** The provider observer reports no open turn. */ +export interface ProviderIdle { + readonly type: "ProviderIdle"; + readonly key: string; +} + +/** Convergence began for the queue head. */ +export interface ConvergenceStarted { + readonly type: "ConvergenceStarted"; + readonly key: string; + readonly id: string; +} + +/** A convergence attempt was invalidated before any byte was sent. */ +export interface ConvergenceInvalidated { + readonly type: "ConvergenceInvalidated"; + readonly key: string; + readonly id: string; + readonly reason: string; +} + +/** + * The intent to deliver, recorded durably *before* the first terminal byte. + * + * This is the record a restart reads to know an outcome is uncertain rather + * than un-attempted. + */ +export interface AttemptStarted { + readonly type: "AttemptStarted"; + readonly key: string; + readonly id: string; +} + +/** The final server-side guard declined the paste; nothing was sent. */ +export interface AttemptDeclined { + readonly type: "AttemptDeclined"; + readonly key: string; + readonly id: string; + readonly reason: string; +} + +/** An attempt whose outcome could not be established; never retried. */ +export interface AttemptUncertain { + readonly type: "AttemptUncertain"; + readonly key: string; + readonly id: string; + readonly reason: string; +} + +/** The exact attempted bytes were observed as a user event under the identity. */ +export interface UserAccepted { + readonly type: "UserAccepted"; + readonly key: string; + readonly id: string; + readonly eventKey: string; + readonly identity: string; + readonly text: string; +} + +/** Assistant output observed under the intended identity. */ +export interface AssistantObserved { + readonly type: "AssistantObserved"; + readonly key: string; + readonly eventKey: string; + readonly identity: string; + readonly text: string; +} + +/** An explicit provider completion boundary closed the turn. */ +export interface AssistantCompleted { + readonly type: "AssistantCompleted"; + readonly key: string; + readonly id: string; + readonly eventKey: string; + readonly identity: string; +} + +/** The observer cursor advanced past a complete, strictly parsed record. */ +export interface ObserverAdvanced { + readonly type: "ObserverAdvanced"; + readonly key: string; + readonly cursor: number; + readonly source: string; +} + +/** The pane exited or was replaced; the role is no longer writable. */ +export interface PaneUnavailable { + readonly type: "PaneUnavailable"; + readonly key: string; + readonly reason: string; +} + +/** The observer refused to advance: ambiguity, truncation, rotation, mismatch. */ +export interface ObserverRefused { + readonly type: "ObserverRefused"; + readonly key: string; + readonly reason: string; +} + +/** The REPL session closed. */ +export interface ReplClosed { + readonly type: "ReplClosed"; +} + +/** The whole closed vocabulary. */ +export type ReplAction = + | ReplOpened + | RoleBound + | MessageQueued + | TerminalObserved + | ProviderBusy + | ProviderIdle + | ConvergenceStarted + | ConvergenceInvalidated + | AttemptStarted + | AttemptDeclined + | AttemptUncertain + | UserAccepted + | AssistantObserved + | AssistantCompleted + | ObserverAdvanced + | PaneUnavailable + | ObserverRefused + | ReplClosed; + +/** Every action type name, for a persisted record to be validated against. */ +export const ACTION_TYPES: readonly ReplAction["type"][] = [ + "ReplOpened", + "RoleBound", + "MessageQueued", + "TerminalObserved", + "ProviderBusy", + "ProviderIdle", + "ConvergenceStarted", + "ConvergenceInvalidated", + "AttemptStarted", + "AttemptDeclined", + "AttemptUncertain", + "UserAccepted", + "AssistantObserved", + "AssistantCompleted", + "ObserverAdvanced", + "PaneUnavailable", + "ObserverRefused", + "ReplClosed", +]; diff --git a/packages/terminal-tmux/poc/repl/claude-observer.ts b/packages/terminal-tmux/poc/repl/claude-observer.ts new file mode 100644 index 000000000..14cce7145 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/claude-observer.ts @@ -0,0 +1,93 @@ +/** + * Issue #774 POC — the Claude session-file parser. + * + * Claude Code writes one identity-bearing `.jsonl` file per session under a + * project directory, named by the session identifier. This parser reads only the + * record shapes the observer needs and refuses a relevant record whose required + * members are wrong; the shared observer in `observer.ts` owns the file + * identity, the cursor and the refusals. + * + * Identity comes from the file name, and every relevant record also carries its + * own `sessionId`, so a record planted under another identity is caught rather + * than inherited. Completion is an explicit closing `result` record — the one + * unambiguous boundary this POC accepts. A build whose real format offers no + * such record is reported `PROVIDER_EXCLUDED` rather than having completion + * inferred from anything weaker. + */ + +import type { ParsedRecord, ProviderParser } from "./observer.ts"; + +/** The Claude parser: filename identity, `sessionId`-tagged records. */ +export const claudeParser: ProviderParser = { + provider: "claude", + identityFromName(name) { + return name.endsWith(".jsonl") ? name.slice(0, -".jsonl".length) : undefined; + }, + classify(record) { + const type = record["type"]; + if (type === "user") { + return classifyMessage(record, "user-accepted"); + } + if (type === "assistant") { + return classifyMessage(record, "assistant-output"); + } + if (type === "result") { + return classifyResult(record); + } + // Summaries, system notices and anything else bear on nothing here. + return { kind: "ignore" }; + }, +}; + +/** A `user` or `assistant` record, read for its identity and its text. */ +function classifyMessage( + record: Record, + kind: "user-accepted" | "assistant-output", +): ParsedRecord { + const identity = record["sessionId"]; + if (typeof identity !== "string" || identity.length === 0) { + return { kind: "unsupported", reason: `${kind} record with no sessionId` }; + } + const text = messageText(record["message"]); + if (text === undefined) { + return { kind: "unsupported", reason: `${kind} record with no readable text` }; + } + if (kind === "user-accepted") { + return { kind: "user-accepted", identity, text }; + } + return { kind: "assistant-output", identity, text }; +} + +/** The explicit closing record: `{"type":"result","sessionId":…}`. */ +function classifyResult(record: Record): ParsedRecord { + const identity = record["sessionId"]; + if (typeof identity !== "string" || identity.length === 0) { + return { kind: "unsupported", reason: "result record with no sessionId" }; + } + return { kind: "turn-completed", identity }; +} + +/** Join the text parts of a Claude message, or nothing when there are none. */ +function messageText(message: unknown): string | undefined { + if (!isRecord(message)) { + return undefined; + } + const content = message["content"]; + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return undefined; + } + const parts: string[] = []; + for (const part of content) { + if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string") { + parts.push(part["text"]); + } + } + return parts.length === 0 ? undefined : parts.join(""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/terminal-tmux/poc/repl/codex-observer.ts b/packages/terminal-tmux/poc/repl/codex-observer.ts new file mode 100644 index 000000000..2b7ce3e3b --- /dev/null +++ b/packages/terminal-tmux/poc/repl/codex-observer.ts @@ -0,0 +1,85 @@ +/** + * Issue #774 POC — the Codex rollout-file parser. + * + * Codex maps a thread identity to one rollout `.jsonl` file. A `session_meta` + * header declares the identity once; the `event_msg` records that follow do not + * repeat it, so they inherit the located file's identity. A second `session_meta` + * naming a different identity in the same file is a conflict the shared observer + * refuses. + * + * Only three event shapes bear on the contract: `user_message` is acceptance, + * `agent_message` is assistant output, and `task_complete` is the explicit + * completion boundary. An `event_msg` whose payload is one of those but is + * otherwise malformed is refused rather than skipped; every other event is + * ignored. + */ + +import type { ParsedRecord, ProviderParser } from "./observer.ts"; + +/** The Codex parser: `session_meta` identity, `event_msg` events. */ +export const codexParser: ProviderParser = { + provider: "codex", + identityFromName() { + // Codex names its rollout files by timestamp, not by identity, so the + // identity is only ever read from the `session_meta` record inside. + return undefined; + }, + classify(record) { + const type = record["type"]; + if (type === "session_meta") { + return classifyMeta(record); + } + if (type === "event_msg") { + return classifyEvent(record); + } + return { kind: "ignore" }; + }, +}; + +/** The header record that names the thread. */ +function classifyMeta(record: Record): ParsedRecord { + const payload = record["payload"]; + if (!isRecord(payload) || typeof payload["id"] !== "string" || payload["id"].length === 0) { + return { kind: "unsupported", reason: "session_meta with no payload id" }; + } + return { kind: "identity", identity: payload["id"] }; +} + +/** One `event_msg`, read only for the three payload types that matter. */ +function classifyEvent(record: Record): ParsedRecord { + const payload = record["payload"]; + if (!isRecord(payload)) { + return { kind: "unsupported", reason: "event_msg with no payload" }; + } + const kind = payload["type"]; + if (kind === "user_message") { + return textEvent(payload, "user-accepted", "user_message"); + } + if (kind === "agent_message") { + return textEvent(payload, "assistant-output", "agent_message"); + } + if (kind === "task_complete") { + return { kind: "turn-completed" }; + } + return { kind: "ignore" }; +} + +/** A `user_message` or `agent_message`, read for its `message` text. */ +function textEvent( + payload: Record, + kind: "user-accepted" | "assistant-output", + label: string, +): ParsedRecord { + const text = payload["message"]; + if (typeof text !== "string") { + return { kind: "unsupported", reason: `${label} with no message text` }; + } + if (kind === "user-accepted") { + return { kind: "user-accepted", text }; + } + return { kind: "assistant-output", text }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/terminal-tmux/poc/repl/controller.ts b/packages/terminal-tmux/poc/repl/controller.ts new file mode 100644 index 000000000..ea2d406c4 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/controller.ts @@ -0,0 +1,340 @@ +/** + * Issue #774 POC — the controller that drives the message lifecycle. + * + * It is the only place that reads convergence, delivery and the observer + * together, and every effect it has on the world is a dispatched action: + * + * - `attemptStep` takes one role's queue head from `queued` to a pasted attempt, + * through convergence and the final guard, recording `AttemptStarted` durably + * before a single byte is sent. A pane that is unavailable, replaced, busy, or + * moving keeps the message queued. + * - `observeStep` reads the provider session file forward from the durable + * cursor and turns exact matching records into acceptance and completion. A + * refusal advances no cursor. + * - `reconcileRestart` turns any attempt a restart left in flight into an + * `uncertain` outcome that is never pasted again, though a later exact provider + * event may still resolve it. + * + * Terminal convergence only ever authorizes an attempt. Acceptance and + * completion come from the provider file, and an unproved outcome becomes + * uncertain rather than delivered. + */ + +import type { Operation } from "effection"; +import { converge } from "./convergence.ts"; +import type { PaneProbe } from "./convergence.ts"; +import { deliver } from "./delivery.ts"; +import { hasOpenTurn, locate, read } from "./observer.ts"; +import type { ObservationRefusal, ProviderParser } from "./observer.ts"; +import { queueHead } from "./state.ts"; +import type { NormalizedEvent, RoleState } from "./state.ts"; +import type { ReplStore } from "./store.ts"; + +/** Where a provider's session files for one role are found and read. */ +export interface ObserverSource { + readonly parser: ProviderParser; + /** The directory the provider writes its session files under. */ + readonly directory: string; +} + +/** How a delivery attempt is shaped for one role's pane. */ +export interface DeliveryOptions { + /** The private mode-`0700` directory message files are staged under. */ + readonly messageDir: string; + readonly bracketedPaste: boolean; + readonly submitKey: string; +} + +/** What one attempt step did. */ +export type AttemptResult = + | { + readonly outcome: "pasted"; + readonly id: string; + readonly byteCount: number; + readonly hash: string; + } + | { readonly outcome: "declined"; readonly id: string; readonly reason: string } + | { readonly outcome: "not-ready"; readonly id: string; readonly reason: string } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal } + | { readonly outcome: "skipped"; readonly reason: string }; + +/** What one observation step did. */ +export type ObserveResult = + | { readonly outcome: "advanced"; readonly events: readonly NormalizedEvent[] } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; + +/** The message a role currently has in flight, if any. */ +function inFlightMessage(role: RoleState) { + return role.inFlight === undefined + ? undefined + : role.messages.find((message) => message.id === role.inFlight); +} + +/** The one message currently accepted and awaiting completion, if any. */ +function acceptedMessage(role: RoleState) { + return role.messages.find((message) => message.state === "accepted"); +} + +/** + * Try to deliver one role's queue head. + * + * The order is the contract: prove the pane usable and the same across a + * barrier, prove the provider idle, record the intent, then paste under a final + * guard. Anything unproved leaves the message exactly where it was. + */ +export function attemptStep( + store: ReplStore, + key: string, + probe: PaneProbe, + observer: ObserverSource, + options: DeliveryOptions, +): Operation { + return (function* (): Operation { + const role = store.state().roles[key]; + if (role === undefined) { + return { outcome: "skipped", reason: "unknown-role" }; + } + if (role.readiness === "unavailable") { + return { outcome: "skipped", reason: "pane-unavailable" }; + } + if (role.inFlight !== undefined) { + return { outcome: "skipped", reason: "in-flight" }; + } + const head = queueHead(role); + if (head === undefined) { + return { outcome: "skipped", reason: "queue-empty" }; + } + + const open = yield* providerOpenTurn(observer, role.identity.id); + if (open.outcome === "refused") { + yield* store.dispatch({ type: "ObserverRefused", key, reason: open.refusal }); + return { outcome: "refused", refusal: open.refusal }; + } + + // A pane whose generation moved is a replacement, never silently adopted. + const current = yield* probe.snapshot(); + if (current.generation !== role.paneGeneration) { + yield* store.dispatch({ type: "PaneUnavailable", key, reason: "pane-replaced" }); + return { outcome: "not-ready", id: head.id, reason: "pane-replaced" }; + } + + yield* store.dispatch({ type: "ConvergenceStarted", key, id: head.id }); + const converged = yield* converge(probe, open.openTurn); + if (converged.outcome === "not-ready") { + yield* store.dispatch({ + type: "ConvergenceInvalidated", + key, + id: head.id, + reason: converged.reason, + }); + if (converged.reason === "provider-open-turn") { + yield* store.dispatch({ type: "ProviderBusy", key }); + } + if (converged.reason === "pane-unavailable") { + yield* store.dispatch({ type: "PaneUnavailable", key, reason: "pane-exit" }); + } + return { outcome: "not-ready", id: head.id, reason: converged.reason }; + } + if (converged.guard.generation !== role.paneGeneration) { + yield* store.dispatch({ type: "PaneUnavailable", key, reason: "pane-replaced" }); + return { outcome: "not-ready", id: head.id, reason: "pane-replaced" }; + } + + yield* store.dispatch({ type: "TerminalObserved", key, readiness: "ready" }); + // The durable intent, before any byte reaches the terminal. + yield* store.dispatch({ type: "AttemptStarted", key, id: head.id }); + + const delivered = yield* deliver(probe, { + dir: options.messageDir, + id: head.id, + bytes: head.text, + bracketedPaste: options.bracketedPaste, + submitKey: options.submitKey, + guard: converged.guard, + }); + if (delivered.outcome === "declined") { + yield* store.dispatch({ + type: "AttemptDeclined", + key, + id: head.id, + reason: delivered.reason, + }); + return { outcome: "declined", id: head.id, reason: delivered.reason }; + } + return { + outcome: "pasted", + id: head.id, + byteCount: delivered.byteCount, + hash: delivered.hash, + }; + })(); +} + +/** + * Read the provider session file forward and turn records into state. + * + * A located file is read from the durable cursor. Exact matching user records + * settle an attempt as accepted; assistant output and the completion boundary + * that follow settle it as completed. A refusal advances no cursor and leaves + * every message where it was. + */ +export function observeStep( + store: ReplStore, + key: string, + observer: ObserverSource, +): Operation { + return (function* (): Operation { + const role = store.state().roles[key]; + if (role === undefined) { + return { outcome: "refused", refusal: "not-found" }; + } + const located = yield* locate(observer.parser, observer.directory, role.identity.id); + if (located.outcome === "refused") { + yield* store.dispatch({ type: "ObserverRefused", key, reason: located.refusal }); + return { outcome: "refused", refusal: located.refusal }; + } + // The cursor was established against a particular file identity. Re-locating + // finds the current file, but a change of identity since the cursor was set + // is a rotation — so the remembered key, not the freshly located one, is + // what `read` enforces. + const expectedKey = role.observerSource === "" ? located.source.fileKey : role.observerSource; + const source = { ...located.source, fileKey: expectedKey }; + const readOut = yield* read(observer.parser, source, role.cursor); + if (readOut.outcome === "refused") { + yield* store.dispatch({ type: "ObserverRefused", key, reason: readOut.refusal }); + return { outcome: "refused", refusal: readOut.refusal }; + } + for (const event of readOut.events) { + yield* applyEvent(store, key, event); + } + yield* store.dispatch({ + type: "ObserverAdvanced", + key, + cursor: readOut.cursor, + source: located.source.fileKey, + }); + return { outcome: "advanced", events: readOut.events }; + })(); +} + +/** Fold one normalized event into the store, matching it to a message. */ +function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Operation { + return (function* (): Operation { + const role = store.state().roles[key]; + if (role === undefined) { + return; + } + if (event.kind === "user-accepted") { + // The exact attempted bytes, under the intended identity. A user record + // whose text differs is someone else's turn and settles nothing. + const target = role.messages.find( + (message) => + (message.state === "attempt-started" || message.state === "uncertain") && + message.text === event.text, + ); + if (target !== undefined) { + yield* store.dispatch({ + type: "UserAccepted", + key, + id: target.id, + eventKey: event.key, + identity: event.identity, + text: event.text, + }); + } + return; + } + const accepted = acceptedMessage(role); + if (accepted === undefined) { + return; + } + if (event.kind === "assistant-output") { + yield* store.dispatch({ + type: "AssistantObserved", + key, + eventKey: event.key, + identity: event.identity, + text: event.text, + }); + return; + } + yield* store.dispatch({ + type: "AssistantCompleted", + key, + id: accepted.id, + eventKey: event.key, + identity: event.identity, + }); + })(); +} + +/** + * Settle an attempt whose paste was accepted by tmux but never confirmed. + * + * Called once a bounded observation has shown no exact user event for the + * in-flight attempt: the outcome is uncertain, and the message is never pasted + * again. A later exact provider event may still resolve it through `observeStep`. + */ +export function settleUnconfirmed( + store: ReplStore, + key: string, + reason: string, +): Operation { + return (function* (): Operation { + const role = store.state().roles[key]; + if (role === undefined) { + return false; + } + const message = inFlightMessage(role); + if (message === undefined || message.state !== "attempt-started") { + return false; + } + yield* store.dispatch({ type: "AttemptUncertain", key, id: message.id, reason }); + return true; + })(); +} + +/** + * Reconcile a restarted store. + * + * Any attempt that was in flight when the process stopped is uncertain: tmux may + * have accepted the paste, so it must never be pasted again. Queued work is + * untouched and resumes convergence normally. + */ +export function reconcileRestart(store: ReplStore): Operation { + return (function* (): Operation { + let settled = 0; + for (const role of Object.values(store.state().roles)) { + const message = inFlightMessage(role); + if (message !== undefined && message.state === "attempt-started") { + yield* store.dispatch({ + type: "AttemptUncertain", + key: role.key, + id: message.id, + reason: "restart", + }); + settled += 1; + } + } + return settled; + })(); +} + +/** Whether the provider file shows an open turn now, or a refusal to read it. */ +type OpenTurnOutcome = + | { readonly outcome: "read"; readonly openTurn: boolean } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; + +function providerOpenTurn(observer: ObserverSource, identity: string): Operation { + return (function* (): Operation { + const located = yield* locate(observer.parser, observer.directory, identity); + if (located.outcome === "refused") { + return { outcome: "refused", refusal: located.refusal }; + } + const readOut = yield* read(observer.parser, located.source, 0); + if (readOut.outcome === "refused") { + return { outcome: "refused", refusal: readOut.refusal }; + } + return { outcome: "read", openTurn: hasOpenTurn(readOut.events) }; + })(); +} diff --git a/packages/terminal-tmux/poc/repl/convergence.ts b/packages/terminal-tmux/poc/repl/convergence.ts new file mode 100644 index 000000000..e07f11df0 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/convergence.ts @@ -0,0 +1,144 @@ +/** + * Issue #774 POC — the generic terminal-convergence algorithm. + * + * The unproven boundary this whole experiment exists for: can generic terminal + * state identify a safe point to deliver input without parsing what the agent + * drew on the screen? The algorithm below reads only structural facts about a + * pane — its generation, its process, its terminal, whether it is in a mode, + * how much output and client activity it has seen — plus whether the provider's + * own session file shows an open turn. It never reads prompt wording or screen + * text. + * + * Convergence authorizes an *attempt*. It never establishes acceptance: only the + * provider session file does that. Two structurally equal snapshots separated by + * an acknowledged terminal barrier, with no intervening event and no open + * provider turn, are what "ready" means here. + * + * A `PaneProbe` is the seam. The tmux provider would implement it over a private + * control-mode client; the deterministic suite implements it with a fake pane + * that also holds hidden busy and manual ground truth — exposed only to the + * assertions, never to this algorithm. + */ + +import type { Operation } from "effection"; + +/** A structural reading of one pane. No screen text appears here. */ +export interface PaneSnapshot { + /** The pane generation; a replacement bumps it, and an old one is refused. */ + readonly generation: number; + /** The pane's foreground process id, or a non-positive value when dead. */ + readonly pid: number; + /** The pane's terminal identity, or "" when it has none. */ + readonly terminal: string; + readonly alive: boolean; + /** The pane's input mode: "" is the ordinary input mode, else copy-mode etc. */ + readonly mode: string; + /** The foreground process group leader, distinguishing a child from the shell. */ + readonly foregroundProcess: number; + /** A generation that advances whenever a visible client acts. */ + readonly clientActivity: number; + /** A count of control-mode output events, with the output bytes dropped. */ + readonly outputEvents: number; + /** A counter that advances on *any* observable pane event. */ + readonly epoch: number; +} + +/** A literal paste, described without any bytes crossing an argument vector. */ +export interface PasteRequest { + /** The uniquely named tmux buffer the message bytes were loaded into. */ + readonly buffer: string; + /** Whether bracketed paste is used, when the terminal supports it. */ + readonly bracketedPaste: boolean; + /** The submit key, sent separately from the pasted bytes. */ + readonly submitKey: string; +} + +/** What the one guarded paste operation established. */ +export type GuardOutcome = + | { readonly outcome: "pasted" } + | { readonly outcome: "declined"; readonly reason: string }; + +/** + * The pane operations convergence and delivery need. + * + * Everything here is a structural tmux-shaped verb. `guardedPaste` is the single + * server-side operation that rechecks the pane and either declines or pastes + * without suspending in between — the last gate before bytes reach a terminal. + */ +export interface PaneProbe { + /** Read the pane's current structural state. */ + snapshot(): Operation; + /** An acknowledged terminal round-trip, so two snapshots straddle a barrier. */ + barrier(): Operation; + /** Load the private message file's bytes into a uniquely named buffer. */ + loadBuffer(buffer: string, path: string): Operation; + /** + * Recheck the pane against the converged guard and paste, or decline — in one + * operation, with no suspension between the recheck and the paste. + */ + guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation; +} + +/** The result of one convergence attempt. */ +export type ConvergenceOutcome = + | { readonly outcome: "converged"; readonly guard: PaneSnapshot } + | { readonly outcome: "not-ready"; readonly reason: string }; + +/** + * Attempt to converge one pane to a safe input point. + * + * Takes a snapshot, requires the pane usable and the provider idle, crosses an + * acknowledged barrier, and takes a second snapshot. It converges only when the + * two are structurally equal and no event occurred between them. Any difference + * — including the epoch advancing, which is any observable event at all — is + * `not-ready`, and the caller leaves the message queued. + */ +export function converge( + probe: PaneProbe, + providerOpenTurn: boolean, +): Operation { + return (function* (): Operation { + if (providerOpenTurn) { + return { outcome: "not-ready", reason: "provider-open-turn" }; + } + const first = yield* probe.snapshot(); + const usable = usability(first); + if (usable !== undefined) { + return { outcome: "not-ready", reason: usable }; + } + yield* probe.barrier(); + const second = yield* probe.snapshot(); + if (!structurallyEqual(first, second)) { + return { outcome: "not-ready", reason: "pane-changed" }; + } + if (second.epoch !== first.epoch) { + return { outcome: "not-ready", reason: "intervening-event" }; + } + return { outcome: "converged", guard: second }; + })(); +} + +/** Why a pane is not usable for a delivery attempt, or nothing when it is. */ +function usability(snapshot: PaneSnapshot): string | undefined { + if (!snapshot.alive || snapshot.pid <= 0 || snapshot.terminal.length === 0) { + return "pane-unavailable"; + } + if (snapshot.mode.length > 0) { + return "pane-in-mode"; + } + return undefined; +} + +/** Whether two snapshots agree on every structural fact but the event epoch. */ +export function structurallyEqual(left: PaneSnapshot, right: PaneSnapshot): boolean { + return ( + left.generation === right.generation && + left.pid === right.pid && + left.terminal === right.terminal && + left.alive === right.alive && + left.mode === right.mode && + left.foregroundProcess === right.foregroundProcess && + left.clientActivity === right.clientActivity && + left.outputEvents === right.outputEvents + ); +} diff --git a/packages/terminal-tmux/poc/repl/delivery.ts b/packages/terminal-tmux/poc/repl/delivery.ts new file mode 100644 index 000000000..8ec36deae --- /dev/null +++ b/packages/terminal-tmux/poc/repl/delivery.ts @@ -0,0 +1,91 @@ +/** + * Issue #774 POC — literal terminal delivery. + * + * A message's bytes never touch a shell or a tmux argument vector. They are + * written to a private mode-`0600` file, loaded from that file into a uniquely + * named tmux buffer, and pasted from the buffer; the submit key is a separate + * keystroke, so linefeeds in the message stay in the message rather than + * submitting it. Bracketed paste is used where the terminal supports it. + * + * The paste itself is the final guarded operation: it rechecks the pane against + * the guard convergence produced and either declines or pastes, with no + * suspension in between. An unproved outcome is never a delivery — the caller + * turns a decline into a message that stays queued, and a paste into an attempt + * whose acceptance only the provider file can confirm. + */ + +import { ensure, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import type { GuardOutcome, PaneProbe, PaneSnapshot } from "./convergence.ts"; + +/** Everything one delivery attempt needs. */ +export interface DeliveryRequest { + /** The private mode-`0700` directory the message file is written under. */ + readonly dir: string; + /** The message id, which names both the private file and the tmux buffer. */ + readonly id: string; + /** The literal message bytes. Loaded from a file, never placed in an argv. */ + readonly bytes: string; + /** Whether the pane's terminal supports bracketed paste. */ + readonly bracketedPaste: boolean; + /** The key that submits the pasted message, sent on its own. */ + readonly submitKey: string; + /** The converged snapshot the final guard rechecks against. */ + readonly guard: PaneSnapshot; +} + +/** What a delivery attempt established, with report-safe evidence. */ +export type DeliveryOutcome = + | { + readonly outcome: "pasted"; + /** The exact byte count delivered. */ + readonly byteCount: number; + /** A hash of the delivered bytes; the bytes themselves never leave here. */ + readonly hash: string; + } + | { readonly outcome: "declined"; readonly reason: string }; + +/** A uniquely named tmux buffer for one message. */ +export function bufferName(id: string): string { + return `xmd-repl-${id}`; +} + +/** + * Deliver one message's literal bytes to a pane, under the final guard. + * + * The private file is this scope's: it is removed when the attempt settles, + * whether the guard pasted or declined. + */ +export function deliver(probe: PaneProbe, request: DeliveryRequest): Operation { + return scoped(function* (): Operation { + const path = join(request.dir, `${request.id}.msg`); + yield* writeTextFile(path, request.bytes); + yield* until(chmod(path, 0o600)); + yield* ensure(() => rm(path, { force: true })); + + const buffer = bufferName(request.id); + yield* probe.loadBuffer(buffer, path); + const guarded: GuardOutcome = yield* probe.guardedPaste(request.guard, { + buffer, + bracketedPaste: request.bracketedPaste, + submitKey: request.submitKey, + }); + if (guarded.outcome === "declined") { + return { outcome: "declined", reason: guarded.reason }; + } + return { + outcome: "pasted", + byteCount: new TextEncoder().encode(request.bytes).length, + hash: hashBytes(request.bytes), + }; + }); +} + +/** The lowercase SHA-256 of the delivered bytes, for the report. */ +export function hashBytes(bytes: string): string { + return createHash("sha256").update(bytes, "utf8").digest("hex"); +} diff --git a/packages/terminal-tmux/poc/repl/live-supervisor.ts b/packages/terminal-tmux/poc/repl/live-supervisor.ts new file mode 100644 index 000000000..d6c0f2167 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/live-supervisor.ts @@ -0,0 +1,133 @@ +/** + * Issue #774 POC — the doubly-gated live-proof supervisor. + * + * A live journey launches a real coding agent and spends real model turns, so it + * is refused unless both of its exact gates are supplied for this invocation. + * The gate check is the first thing that happens: before it passes, nothing here + * starts an agent, opens a provider transcript, or reads anything a provider + * owns. Without the gates the supervisor prints a `NOT_AUTHORIZED` report and + * exits cleanly, which is what runs on an ordinary machine and in CI. + * + * Previous authorization does not count. Each provider's gates are read from the + * live environment of this exact invocation, and the turn-count gate must carry + * the exact value the journey is allowed to spend — one for Claude, two for + * Codex. + * + * When the gates are present, the journey itself lives in `live-worker.ts`; this + * module only decides whether it may run and shapes the report either way. + */ + +import { main } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { notAuthorizedReport } from "./report.ts"; +import type { ReportMode, TerminalReplReport } from "./report.ts"; +import { runLiveJourney } from "./live-worker.ts"; + +/** The provider a live journey targets. */ +export type LiveProvider = "claude" | "codex"; + +/** One provider's exact gates and the turn value its journey may spend. */ +interface Gate { + readonly proofEnv: string; + readonly turnsEnv: string; + readonly turnsValue: string; + readonly mode: ReportMode; +} + +const GATES: Readonly> = { + claude: { + proofEnv: "XMD_TERMINAL_REPL_CLAUDE_PROOF", + turnsEnv: "XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED", + turnsValue: "1", + mode: "live-claude", + }, + codex: { + proofEnv: "XMD_TERMINAL_REPL_CODEX_PROOF", + turnsEnv: "XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED", + turnsValue: "2", + mode: "live-codex", + }, +}; + +/** Whether both of a provider's gates are supplied with their exact values. */ +export function gatesSatisfied( + provider: LiveProvider, + env: Record, +): boolean { + const gate = GATES[provider]; + return env[gate.proofEnv] === "1" && env[gate.turnsEnv] === gate.turnsValue; +} + +/** The two exact commands a reviewer runs to authorize each live journey. */ +export const LIVE_COMMANDS: Readonly> = { + claude: + "XMD_TERMINAL_REPL_CLAUDE_PROOF=1 XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1 " + + "deno task xmd test packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md --raw", + codex: + "XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 " + + "deno task xmd test packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md --raw", +}; + +/** The runtime this supervisor runs under, for the report's provenance. */ +function runtimeName(): string { + const globals = globalThis as { Deno?: unknown; Bun?: unknown }; + if (globals.Deno !== undefined) { + return "deno"; + } + if (globals.Bun !== undefined) { + return "bun"; + } + return "node"; +} + +/** + * Decide whether a live journey may run, and produce its report either way. + * + * The gate check comes first and touches nothing a provider owns. A refusal + * returns a `NOT_AUTHORIZED` report that spent no turn; a pass hands off to the + * journey. + */ +export function runLiveProof( + provider: LiveProvider, + env: Record, + base: string, +): Operation { + return (function* (): Operation { + const gate = GATES[provider]; + if (!gatesSatisfied(provider, env)) { + return notAuthorizedReport(gate.mode, runtimeName(), base); + } + // Only past the gate does anything provider-facing begin. + return yield* runLiveJourney(provider, env, base); + })(); +} + +/** The base commit this POC was built from, recorded in every report. */ +const BASE_SHA = "97fda6aa7b5f85db747c066898fd3ef3c6d1dbeb"; + +/** Parse the provider argument, refusing anything but the two supported names. */ +function providerArgument(argv: readonly string[]): LiveProvider | undefined { + const [name] = argv; + if (name === "claude" || name === "codex") { + return name; + } + return undefined; +} + +// Runnable under Deno as `deno run ... live-supervisor.ts `. It +// prints one report as JSON and exits 0; a nonzero exit means the supervisor +// itself broke, never that a journey reached a verdict. +if (import.meta.main) { + await main(function* (): Operation { + const provider = providerArgument(process.argv.slice(2)); + if (provider === undefined) { + process.stdout.write( + `${JSON.stringify({ error: "usage: live-supervisor.ts " })}\n`, + ); + return; + } + const report = yield* runLiveProof(provider, process.env, BASE_SHA); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + }); +} diff --git a/packages/terminal-tmux/poc/repl/live-worker.ts b/packages/terminal-tmux/poc/repl/live-worker.ts new file mode 100644 index 000000000..d9b48c1e5 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/live-worker.ts @@ -0,0 +1,642 @@ +/** + * Issue #774 POC — the live journey, run only under fresh authorization. + * + * This is the code the supervisor reaches once both gates are satisfied. It is + * deliberately never exercised during implementation or ordinary CI: it launches + * the exact compiled `./dist/xmd run` command, which opens a real terminal grid + * with real coding agents, and it spends real model turns. Its correctness is + * settled by the authorized live run, not by the offline suite, so it is written + * to be coherent and refusal-first and is left for that run to harden. + * + * What it does when authorized: + * + * - It builds a private `HOME` and `TMPDIR`, so `.xmd`, `.acpx`, the adapters, + * the launch journal, the tmux server and the POC store are all isolated. It + * keeps access to the operator's authenticated provider configuration by + * pointing `CLAUDE_CONFIG_DIR` / `CODEX_HOME` at the operator's real ones + * rather than relocating or copying them — relocating those de-authenticates + * the agent. + * - It launches `./dist/xmd run TerminalReplLive.md --journal ` under a + * private pseudo-terminal, discovers the grid's tmux socket beneath the owned + * `TMPDIR`, and reads each role's exact native identity from the isolated + * launch journal — never from directory recency. + * - It drives one uniquely-marked message per role through the same controller + * the offline suite proves, over a real tmux `PaneProbe`, and observes + * acceptance and completion from the provider's own session file. + * - It cleans provider state through the provider's exact supported operation + * (`claude project purge`, `codex delete`) and removes only the roots it owns. + * It never sweeps a shared provider directory. + * + * One boundary the plan's "discover only beneath the POC-owned TMPDIR" cannot + * cover: a provider writes its session file under its own configuration, not + * under `TMPDIR`. Those files are therefore located by the exact identity the + * launch journal retained and by the exact temporary project path, and are never + * swept — the same discipline the repository's own real-agent proofs use. + */ + +import { ensure, race, resource, sleep, until, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { exec } from "@effectionx/process"; +import { ensureDir, exists, readTextFile, readdir, rm } from "@effectionx/fs"; +import { chmod, copyFile, mkdtemp, realpath } from "node:fs/promises"; +import { spawn as spawnChild } from "node:child_process"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import process from "node:process"; +import type { GuardOutcome, PaneProbe, PaneSnapshot, PasteRequest } from "./convergence.ts"; +import { structurallyEqual } from "./convergence.ts"; +import { attemptStep, observeStep, settleUnconfirmed } from "./controller.ts"; +import type { DeliveryOptions, ObserverSource } from "./controller.ts"; +import { useReplStore } from "./store.ts"; +import type { ReplStore } from "./store.ts"; +import { claudeParser } from "./claude-observer.ts"; +import { codexParser } from "./codex-observer.ts"; +import type { ProviderParser } from "./observer.ts"; +import type { LiveProvider } from "./live-supervisor.ts"; +import type { DeliveryEvidence, ReportCounters, ReportMode, TerminalReplReport } from "./report.ts"; +import { identityHash, zeroCounters } from "./report.ts"; + +/** The repository root, four levels up from this module. */ +const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +/** The exact compiled binary the live journey launches. */ +const XMD_BINARY = join(REPO_ROOT, "dist", "xmd"); +/** The live grid document this journey runs. */ +const LIVE_DOCUMENT = fileURLToPath(new URL("./TerminalReplLive.md", import.meta.url)); + +/** The isolated roots one live journey owns. */ +interface Roots { + /** The POC-owned temporary root; everything below is removed with it. */ + readonly root: string; + readonly home: string; + readonly tmp: string; + readonly project: string; + readonly journal: string; + readonly storeDir: string; + readonly messageDir: string; + /** The environment handed to the launched child. */ + readonly env: Record; +} + +/** + * Build the isolated roots for a live journey. + * + * Private `HOME` and `TMPDIR` isolate everything XMD owns; the operator's real + * provider configuration is reached by its own variable so the agent stays + * authenticated. The whole root is removed when the scope ends. + */ +export function useIsolatedRoots(): Operation { + return resource(function* (provide) { + const operatorHome = process.env.HOME ?? homedir(); + const root = yield* until(mkdtemp(join(tmpdir(), "xmd-repl-live-"))); + yield* until(chmod(root, 0o700)); + yield* ensure(() => rm(root, { recursive: true, force: true })); + + const home = join(root, "home"); + const tmp = join(root, "tmp"); + const project = join(root, "project"); + const storeDir = join(root, "store"); + const messageDir = join(root, "messages"); + yield* ensureDir(home); + yield* ensureDir(tmp); + yield* ensureDir(join(project, ".agents")); + yield* ensureDir(messageDir); + yield* until(chmod(messageDir, 0o700)); + + // A byte-for-byte copy of the role documents, so the production target + // resolves without creating provider state for the repository itself. + yield* until(copyFile(join(REPO_ROOT, "AGENTS.md"), join(project, "AGENTS.md"))); + yield* until( + copyFile( + join(REPO_ROOT, ".agents", "implementor.md"), + join(project, ".agents", "implementor.md"), + ), + ); + + const env: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[name] = value; + } + } + env.HOME = home; + env.TMPDIR = tmp; + // Keep the operator's authenticated provider configuration reachable without + // relocating or copying it. Left as the operator set it when already set. + env.CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? join(operatorHome, ".claude"); + env.CODEX_HOME = process.env.CODEX_HOME ?? join(operatorHome, ".codex"); + + yield* provide({ + root, + home, + tmp, + project: yield* until(realpath(project)), + journal: join(root, "launch-journal.jsonl"), + storeDir, + messageDir, + env, + }); + }); +} + +/** Find the grid's private tmux socket beneath the owned TMPDIR, if it exists. */ +export function discoverGridSocket(tmp: string): Operation { + return (function* (): Operation { + let names: string[]; + try { + names = yield* readdir(tmp); + } catch { + return undefined; + } + for (const name of names) { + if (name.startsWith("xmd-grid-")) { + const socket = join(tmp, name, "s"); + if (yield* exists(socket)) { + return socket; + } + } + } + return undefined; + })(); +} + +/** Run one tmux command on the private server and return its trimmed stdout. */ +function tmuxRun( + socket: string, + args: readonly string[], + env: Record, +): Operation { + return (function* (): Operation { + const result = yield* exec("tmux", { + arguments: ["-S", socket, "-f", "/dev/null", ...args], + env, + }).join(); + return result.code === 0 ? result.stdout.trim() : ""; + })(); +} + +/** + * A real `PaneProbe` over the grid's private tmux server. + * + * Snapshots come from tmux format variables — never from screen text. The + * guarded paste is one server-side `if-shell -F` that rechecks the pane's + * process and pastes the pre-loaded buffer with a separate submit key, so the + * recheck and the paste share one command queue. + */ +export function tmuxPaneProbe( + socket: string, + target: string, + env: Record, +): PaneProbe { + function readSnapshot(): Operation { + return (function* (): Operation { + const format = + "#{pane_pid}|#{pane_tty}|#{pane_dead}|#{pane_in_mode}|#{pane_current_command}|" + + "#{history_size}|#{session_activity}|#{window_activity}"; + const line = yield* tmuxRun(socket, ["display", "-p", "-t", target, format], env); + const [pid, tty, dead, mode, command, history, sessionActivity, windowActivity] = + line.split("|"); + const alive = dead === "0" && (pid ?? "").length > 0; + const outputEvents = Number(history ?? "0"); + const clientActivity = Number(sessionActivity ?? "0") + Number(windowActivity ?? "0"); + return { + generation: 0, + pid: alive ? Number(pid) : -1, + terminal: alive ? (tty ?? "") : "", + alive, + mode: mode === "1" ? "copy" : "", + foregroundProcess: commandHash(command ?? ""), + clientActivity, + outputEvents, + epoch: outputEvents + clientActivity, + }; + })(); + } + + return { + snapshot: readSnapshot, + *barrier(): Operation { + // An acknowledged round-trip: displaying a constant waits for the server + // to answer without changing anything. + yield* tmuxRun(socket, ["display", "-p", "-t", target, "barrier"], env); + }, + *loadBuffer(buffer, path): Operation { + yield* tmuxRun(socket, ["load-buffer", "-b", buffer, path], env); + }, + *guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation { + const current = yield* readSnapshot(); + if (!structurallyEqual(guard, current) || guard.epoch !== current.epoch) { + return { outcome: "declined", reason: "guard-changed" }; + } + if (!current.alive) { + return { outcome: "declined", reason: "pane-unavailable" }; + } + // One server-side recheck of the pane's process, then the paste, in one + // queued command. The buffer bytes never reach a shell or an argv. + const bracket = delivery.bracketedPaste ? "-p" : ""; + const pasteArgs = [ + "paste-buffer", + "-b", + delivery.buffer, + "-t", + target, + ...(bracket ? [bracket] : []), + ]; + const guardExpr = `#{==:#{pane_pid},${current.pid}}`; + const pasteLine = pasteArgs.map(quoteTmux).join(" "); + const submitLine = ["send-keys", "-t", target, delivery.submitKey].map(quoteTmux).join(" "); + yield* tmuxRun( + socket, + ["if-shell", "-F", guardExpr, `${pasteLine} ; ${submitLine}`, "display -p declined"], + env, + ); + return { outcome: "pasted" }; + }, + }; +} + +/** A stable number for a pane's foreground command, distinguishing child from shell. */ +function commandHash(command: string): number { + let hash = 0; + for (const character of command) { + hash = (hash * 31 + character.charCodeAt(0)) % 1_000_000_007; + } + return hash; +} + +/** Quote one tmux argument for an `if-shell` command string. */ +function quoteTmux(argument: string): string { + return `'${argument.replaceAll("'", `'\\''`)}'`; +} + +/** One role's native identity, read from the isolated launch journal. */ +export interface LaunchIdentity { + readonly provider: LiveProvider; + readonly id: string; +} + +/** + * Read each role's exact native identity from the isolated launch journal. + * + * The journal is XMD's own diagnostic record, not a provider file. Identity is + * taken from the `agent_session_launch` records it retained, never inferred from + * which provider file was touched most recently. + */ +export function readLaunchIdentities(journalPath: string): Operation> { + return (function* (): Operation> { + const identities = new Map(); + if (!(yield* exists(journalPath))) { + return identities; + } + const text = yield* readTextFile(journalPath); + for (const raw of text.split("\n")) { + if (raw.trim().length === 0) { + continue; + } + let event: unknown; + try { + event = JSON.parse(raw); + } catch { + continue; + } + const record = launchRecord(event); + if (record !== undefined) { + identities.set(record.agent, { provider: record.provider, id: record.id }); + } + } + return identities; + })(); +} + +/** One `agent_session_launch` prepared record, or nothing. */ +function launchRecord( + event: unknown, +): { agent: string; provider: LiveProvider; id: string } | undefined { + if (!isRecord(event) || event.type !== "yield" || !isRecord(event.description)) { + return undefined; + } + if (event.description.type !== "agent_session_launch") { + return undefined; + } + const result = event.result; + if (!isRecord(result) || result.status !== "ok" || !isRecord(result.value)) { + return undefined; + } + const value = result.value; + const agent = value.agent; + const id = value.nativeSessionId; + if (typeof agent !== "string" || typeof id !== "string" || id.length === 0) { + return undefined; + } + if (agent !== "claude" && agent !== "codex") { + return undefined; + } + return { agent, provider: agent, id }; +} + +/** The provider parser and session directory for one live provider. */ +function providerObserver( + provider: LiveProvider, + roots: Roots, +): { parser: ProviderParser; directory: string } { + if (provider === "claude") { + // Claude keys a project by its resolved cwd under its configuration. + const encoded = roots.project.replaceAll("/", "-"); + return { + parser: claudeParser, + directory: join(roots.env.CLAUDE_CONFIG_DIR, "projects", encoded), + }; + } + return { parser: codexParser, directory: join(roots.env.CODEX_HOME, "sessions") }; +} + +/** How long the journey waits for a live fact before calling it a hang. */ +const SOCKET_DEADLINE_MS = 60_000; +const IDENTITY_DEADLINE_MS = 120_000; +const ACCEPT_DEADLINE_MS = 180_000; +const POLL_MS = 500; +const CHILD_TEARDOWN_MS = 8_000; + +/** + * Run one authorized live journey and return its report. + * + * Reached only past the supervisor's gate, and unexercised until an authorized + * run supplies the turns it spends. It launches the grid, discovers its private + * socket, reads the target role's exact native identity from the isolated + * journal, and drives one uniquely-marked message through the same controller the + * offline suite proves, over the real tmux probe. A deadline only diagnoses a + * hang; acceptance is the provider's exact user event and nothing else. Any setup + * the environment cannot supply is reported `HARNESS_FAILED`, so a review never + * mistakes it for a product finding. + */ +export function runLiveJourney( + provider: LiveProvider, + _env: Record, + base: string, +): Operation { + return resource(function* (provide) { + const mode: ReportMode = provider === "claude" ? "live-claude" : "live-codex"; + if (!(yield* exists(XMD_BINARY))) { + yield* provide(harnessFailed(mode, base, "dist/xmd is not built; run deno task build")); + return; + } + const roots = yield* useIsolatedRoots(); + const store = yield* useReplStore(roots.storeDir); + const counters: { -readonly [K in keyof ReportCounters]: ReportCounters[K] } = zeroCounters(); + const deliveries: DeliveryEvidence[] = []; + + // Launch the grid document under a private pseudo-terminal. Its streams are + // ignored: the grid draws on its own pty, and the POC reads provider files + // and delivers through tmux, never through this child's stdio. + const child = spawnChild( + "/usr/bin/script", + ["-q", "/dev/null", XMD_BINARY, "run", LIVE_DOCUMENT, "--journal", roots.journal, "--raw"], + { cwd: roots.project, env: roots.env, stdio: "ignore" }, + ); + // The `close` handler is named and removed in the same teardown that waits on + // it: kept through the wait, taken off in a synchronous finally. + const closed = withResolvers(); + let didClose = false; + const onClose = (): void => { + didClose = true; + closed.resolve(); + }; + yield* ensure(function* () { + try { + if (!didClose && child.pid !== undefined) { + child.kill("SIGINT"); + yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); + if (!didClose) { + child.kill("SIGKILL"); + } + } + } finally { + child.off("close", onClose); + } + }); + child.on("close", onClose); + + const socket = yield* waitFor(SOCKET_DEADLINE_MS, () => discoverGridSocket(roots.tmp)); + if (socket === undefined) { + yield* provide( + harnessFailed(mode, base, "the grid's tmux socket never appeared under the owned TMPDIR"), + ); + return; + } + const identity = yield* waitFor(IDENTITY_DEADLINE_MS, function* () { + const identities = yield* readLaunchIdentities(roots.journal); + return [...identities.values()].find((entry) => entry.provider === provider); + }); + if (identity === undefined) { + yield* provide( + harnessFailed( + mode, + base, + "the launch journal never retained this provider's native identity", + ), + ); + return; + } + + const key = identity.id; + const target = `xmd:0.${provider === "claude" ? 0 : 1}`; + const probe = tmuxPaneProbe(socket, target, roots.env); + const observer: ObserverSource = providerObserver(provider, roots); + const options: DeliveryOptions = { + messageDir: roots.messageDir, + bracketedPaste: true, + submitKey: "Enter", + }; + const first = yield* probe.snapshot(); + + yield* store.dispatch({ type: "ReplOpened", replSession: `live-${provider}` }); + yield* store.dispatch({ + type: "RoleBound", + key, + role: provider === "claude" ? "Implementor" : "Reviewer", + issue: "#774", + identity, + paneGeneration: first.generation, + }); + const marker = `MK-${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + const messageId = `msg-${randomUUID().slice(0, 8)}`; + const text = `Reply with exactly this token on its own line and nothing else: ${marker}`; + yield* store.dispatch({ type: "MessageQueued", key, id: messageId, text, marker }); + + // Attempt until admitted, then observe until the exact user event and its + // completion appear, or a deadline diagnoses a hang and marks it uncertain. + const attempt = yield* driveUntilPasted( + store, + key, + probe, + observer, + options, + counters, + deliveries, + ); + let accepted = false; + let completed = false; + if (attempt) { + const deadline = Date.now() + ACCEPT_DEADLINE_MS; + while (Date.now() < deadline) { + yield* observeStep(store, key, observer); + const role = store.state().roles[key]; + const message = role?.messages.find((entry) => entry.id === messageId); + accepted = message?.state === "accepted" || message?.state === "completed"; + completed = message?.state === "completed"; + if (completed) { + break; + } + yield* sleep(POLL_MS); + } + if (!accepted) { + yield* settleUnconfirmed(store, key, "no-acceptance-before-deadline"); + counters.uncertain += 1; + } + } + + yield* provide( + liveReport({ + provider, + mode, + base, + identity: key, + accepted, + completed, + counters, + deliveries, + }), + ); + }); +} + +/** Poll `probe` until it yields a value or the deadline is spent. */ +function waitFor( + deadlineMs: number, + probe: () => Operation, +): Operation { + return (function* (): Operation { + const deadline = Date.now() + deadlineMs; + while (Date.now() < deadline) { + const value = yield* probe(); + if (value !== undefined) { + return value; + } + yield* sleep(POLL_MS); + } + return undefined; + })(); +} + +/** Attempt delivery until one paste is admitted, or a deadline gives up. */ +function driveUntilPasted( + store: ReplStore, + key: string, + probe: PaneProbe, + observer: ObserverSource, + options: DeliveryOptions, + counters: { -readonly [K in keyof ReportCounters]: ReportCounters[K] }, + deliveries: DeliveryEvidence[], +): Operation { + return (function* (): Operation { + const deadline = Date.now() + ACCEPT_DEADLINE_MS; + while (Date.now() < deadline) { + yield* observeStep(store, key, observer); + const attempt = yield* attemptStep(store, key, probe, observer, options); + counters.convergenceAttempts += 1; + if (attempt.outcome === "pasted") { + counters.admittedDeliveries += 1; + deliveries.push({ messageHash: attempt.hash, byteCount: attempt.byteCount }); + return true; + } + if (attempt.outcome === "refused") { + counters.refusals += 1; + } + yield* sleep(POLL_MS); + } + return false; + })(); +} + +/** Build the report for one completed (or view-only) live journey. */ +function liveReport(inputs: { + provider: LiveProvider; + mode: ReportMode; + base: string; + identity: string; + accepted: boolean; + completed: boolean; + counters: ReportCounters; + deliveries: readonly DeliveryEvidence[]; +}): TerminalReplReport { + const passed = + inputs.accepted && + inputs.completed && + inputs.counters.wrongPaneDeliveries === 0 && + inputs.counters.busyAdmissions === 0 && + inputs.counters.manualActivityAdmissions === 0; + const providerVerdict = passed ? "PASS" : "VIEW_ONLY"; + const claudeSpent = inputs.provider === "claude" ? inputs.counters.admittedDeliveries : 0; + const codexSpent = inputs.provider === "codex" ? inputs.counters.admittedDeliveries : 0; + return { + schema: "terminal-repl-poc-report.v1", + verdict: passed ? "PASS" : "VIEW_ONLY", + mode: inputs.mode, + runtime: "deno", + base: { sha: inputs.base }, + providers: { + claude: + inputs.provider === "claude" + ? { + verdict: providerVerdict, + versionKnown: true, + identityHash: identityHash(inputs.identity), + } + : { verdict: "n/a", versionKnown: false }, + codex: + inputs.provider === "codex" + ? { + verdict: providerVerdict, + versionKnown: true, + identityHash: identityHash(inputs.identity), + } + : { verdict: "n/a", versionKnown: false }, + }, + turnBudgets: { + claudeAuthorized: inputs.provider === "claude" ? 1 : 0, + claudeSpent, + codexAuthorized: inputs.provider === "codex" ? 2 : 0, + codexSpent, + }, + matrix: [], + counters: inputs.counters, + deliveries: [...inputs.deliveries], + restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, + cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + }; +} + +/** A report for a live journey the environment or harness could not complete. */ +function harnessFailed(mode: ReportMode, base: string, detail: string): TerminalReplReport { + const runtime = "deno"; + const provider = { verdict: "n/a" as const, versionKnown: false }; + return { + schema: "terminal-repl-poc-report.v1", + verdict: "HARNESS_FAILED", + mode, + runtime, + detail, + base: { sha: base }, + providers: { claude: provider, codex: provider }, + turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, + matrix: [], + counters: zeroCounters(), + restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, + cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/terminal-tmux/poc/repl/observer.ts b/packages/terminal-tmux/poc/repl/observer.ts new file mode 100644 index 000000000..11f42a29f --- /dev/null +++ b/packages/terminal-tmux/poc/repl/observer.ts @@ -0,0 +1,313 @@ +/** + * Issue #774 POC — the strict, read-only provider session-file observer. + * + * One boundary reads a coding agent's own append-only session file and reports + * exactly three normalized things: that the exact attempted bytes were accepted + * as a user turn, that the assistant produced output, and that an explicit + * provider completion boundary closed the turn. Everything else the file + * contains is either skipped or refused; nothing is inferred. + * + * The rules that keep it honest: + * + * - It matches only the exact native identity the isolated launch journal + * retained. A relevant record under any other identity refuses observation + * rather than being read. + * - The cursor advances only past a complete, strictly parsed record. A partial + * tail is retained and reread, so a half-written record never emits an event + * or moves the cursor. + * - Ambiguity (zero or several files for one identity), truncation (the file is + * shorter than the cursor), rotation (the file identity changed), and an + * unsupported relevant shape each refuse, and a refusal never advances the + * cursor. + * - It only ever reads. It never writes, repairs, truncates, renames or sweeps a + * provider-owned file. + * + * A provider supplies only how to read *its* records; this module owns the file + * identity, the byte accounting and the refusals. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { NormalizedEvent, Provider } from "./state.ts"; + +/** Why an observation was refused. Each maps to a role staying safe. */ +export type ObservationRefusal = + | "not-found" + | "identity-ambiguous" + | "identity-mismatch" + | "truncation" + | "rotation" + | "unsupported-shape"; + +/** One located provider file, pinned to the identity found inside it. */ +export interface ObservedSource { + readonly path: string; + /** The exact native identity this file belongs to. */ + readonly identity: string; + /** A file-identity token; a change in it is a rotation, not new content. */ + readonly fileKey: string; +} + +/** How one provider reads its own records. The shared observer owns the rest. */ +export interface ProviderParser { + readonly provider: Provider; + /** The identity a file name declares, when the provider encodes it there. */ + identityFromName(name: string): string | undefined; + /** Read one already-parsed JSON record into a normalized classification. */ + classify(record: Record): ParsedRecord; +} + +/** + * What one provider record turns out to be. + * + * A relevant record's `identity` is optional: some formats repeat the identity + * on every record (Claude carries `sessionId`), and some declare it once in a + * header and leave later records to inherit it (Codex's `session_meta`). An + * `undefined` identity inherits the located file's; a present one is checked + * against it exactly, and a mismatch refuses. + */ +export type ParsedRecord = + | { readonly kind: "identity"; readonly identity: string } + | { readonly kind: "user-accepted"; readonly identity?: string; readonly text: string } + | { readonly kind: "assistant-output"; readonly identity?: string; readonly text: string } + | { readonly kind: "turn-completed"; readonly identity?: string } + /** A record with no bearing on acceptance or completion. */ + | { readonly kind: "ignore" } + /** A relevant record whose required shape is wrong: refuse, never skip. */ + | { readonly kind: "unsupported"; readonly reason: string }; + +/** The result of locating a provider file for one exact identity. */ +export type LocateOutcome = + | { readonly outcome: "located"; readonly source: ObservedSource } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; + +/** The result of reading new records since a cursor. */ +export type ReadOutcome = + | { + readonly outcome: "advanced"; + readonly events: readonly NormalizedEvent[]; + readonly cursor: number; + } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; + +/** + * Whether the events observed so far leave a turn open. + * + * A turn is open when a user or assistant event follows the last completion + * boundary. Convergence reads this — never terminal wording — to decide the + * provider is idle enough to attempt an input. + */ +export function hasOpenTurn(events: readonly NormalizedEvent[]): boolean { + let open = false; + for (const event of events) { + if (event.kind === "turn-completed") { + open = false; + } else { + open = true; + } + } + return open; +} + +/** + * Find the one file whose native identity matches `expected`, exactly. + * + * Every `.jsonl` under `directory` is a candidate; a file is a match when its + * name declares the identity or a record inside it does. Zero matches is + * `not-found`; more than one is `identity-ambiguous`. Newest-file and + * most-recently-modified heuristics are deliberately not used. + */ +export function locate( + parser: ProviderParser, + directory: string, + expected: string, +): Operation { + return (function* (): Operation { + let names: string[]; + try { + names = (yield* until(readdir(directory))).filter((name) => name.endsWith(".jsonl")); + } catch { + return { outcome: "refused", refusal: "not-found" }; + } + const matches: ObservedSource[] = []; + for (const name of names) { + const path = join(directory, name); + const fromName = parser.identityFromName(name); + const declares = fromName === expected || (yield* declaresIdentity(parser, path, expected)); + if (declares) { + matches.push({ path, identity: expected, fileKey: yield* fileIdentity(path) }); + } + } + if (matches.length === 0) { + return { outcome: "refused", refusal: "not-found" }; + } + if (matches.length > 1) { + return { outcome: "refused", refusal: "identity-ambiguous" }; + } + const [source] = matches; + if (source === undefined) { + return { outcome: "refused", refusal: "not-found" }; + } + return { outcome: "located", source }; + })(); +} + +/** Whether any record in `path` declares `expected` as its identity. */ +function declaresIdentity( + parser: ProviderParser, + path: string, + expected: string, +): Operation { + return (function* (): Operation { + let bytes: Uint8Array; + try { + bytes = yield* until(readFile(path)); + } catch { + return false; + } + const text = new TextDecoder().decode(bytes); + for (const line of text.split("\n")) { + if (line.trim().length === 0) { + continue; + } + let record: unknown; + try { + record = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(record)) { + continue; + } + const parsed = parser.classify(record); + if (parsed.kind === "identity" && parsed.identity === expected) { + return true; + } + } + return false; + })(); +} + +/** + * Read every complete record after `cursor`, and report the normalized events. + * + * The cursor is a byte offset. A trailing record with no newline is a partial + * tail: it is retained, emits nothing, and does not move the cursor. A refusal + * — truncation, rotation, an unsupported relevant shape, or a relevant record + * under the wrong identity — leaves the cursor exactly where it was. + */ +export function read( + parser: ProviderParser, + source: ObservedSource, + cursor: number, +): Operation { + return (function* (): Operation { + let currentKey: string; + let bytes: Uint8Array; + try { + currentKey = yield* fileIdentity(source.path); + bytes = yield* until(readFile(source.path)); + } catch { + return { outcome: "refused", refusal: "rotation" }; + } + if (currentKey !== source.fileKey) { + return { outcome: "refused", refusal: "rotation" }; + } + if (bytes.length < cursor) { + return { outcome: "refused", refusal: "truncation" }; + } + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const tail = decoder.decode(bytes.subarray(cursor)); + const segments = tail.split("\n"); + // The last segment has no terminating newline: it is the partial tail. + const complete = segments.slice(0, -1); + + const events: NormalizedEvent[] = []; + let advanced = cursor; + for (const line of complete) { + const lineBytes = encoder.encode(line).length + 1; + const start = advanced; + const end = advanced + lineBytes; + if (line.trim().length === 0) { + advanced = end; + continue; + } + let record: unknown; + try { + record = JSON.parse(line); + } catch { + return { outcome: "refused", refusal: "unsupported-shape" }; + } + if (!isRecord(record)) { + return { outcome: "refused", refusal: "unsupported-shape" }; + } + const parsed = parser.classify(record); + const step = classifyStep(parsed, source.identity, `${source.fileKey}:${start}-${end}`); + if (step.outcome === "refused") { + return step; + } + if (step.event !== undefined) { + events.push(step.event); + } + advanced = end; + } + return { outcome: "advanced", events, cursor: advanced }; + })(); +} + +/** One record's contribution, or the refusal it forces. */ +type Step = + | { readonly outcome: "kept"; readonly event: NormalizedEvent | undefined } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; + +/** A relevant record's identity is either absent (inherit) or exact. */ +function identityMatches(recorded: string | undefined, expected: string): boolean { + return recorded === undefined || recorded === expected; +} + +function classifyStep(parsed: ParsedRecord, identity: string, key: string): Step { + switch (parsed.kind) { + case "ignore": + return { outcome: "kept", event: undefined }; + case "identity": + if (parsed.identity !== identity) { + return { outcome: "refused", refusal: "identity-mismatch" }; + } + return { outcome: "kept", event: undefined }; + case "unsupported": + return { outcome: "refused", refusal: "unsupported-shape" }; + case "user-accepted": + case "assistant-output": + if (!identityMatches(parsed.identity, identity)) { + return { outcome: "refused", refusal: "identity-mismatch" }; + } + return { + outcome: "kept", + event: { kind: parsed.kind, key, identity, text: parsed.text }, + }; + case "turn-completed": + if (!identityMatches(parsed.identity, identity)) { + return { outcome: "refused", refusal: "identity-mismatch" }; + } + return { + outcome: "kept", + event: { kind: "turn-completed", key, identity, text: "" }, + }; + } +} + +/** A stable file-identity token; a change means the file was replaced. */ +function fileIdentity(path: string): Operation { + return (function* (): Operation { + const info = yield* until(stat(path)); + return `${info.dev}:${info.ino}`; + })(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/terminal-tmux/poc/repl/report.schema.json b/packages/terminal-tmux/poc/repl/report.schema.json new file mode 100644 index 000000000..2f9a99795 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/report.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "terminal-repl-poc-report.v1", + "title": "Terminal REPL POC report", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "verdict", + "mode", + "runtime", + "base", + "providers", + "turnBudgets", + "matrix", + "counters", + "restart", + "cleanup" + ], + "properties": { + "schema": { "const": "terminal-repl-poc-report.v1" }, + "verdict": { + "enum": [ + "PASS", + "VIEW_ONLY", + "PROVIDER_EXCLUDED", + "ENVIRONMENT_BLOCKED", + "HARNESS_FAILED", + "NOT_AUTHORIZED" + ] + }, + "mode": { "enum": ["deterministic", "live-claude", "live-codex"] }, + "runtime": { "type": "string" }, + "detail": { "type": "string" }, + "base": { + "type": "object", + "additionalProperties": false, + "required": ["sha"], + "properties": { + "sha": { "type": "string" }, + "parent": { "type": "string" } + } + }, + "head": { + "type": "object", + "additionalProperties": false, + "properties": { "sha": { "type": "string" } } + }, + "providers": { + "type": "object", + "additionalProperties": false, + "required": ["claude", "codex"], + "properties": { + "claude": { "$ref": "#/definitions/provider" }, + "codex": { "$ref": "#/definitions/provider" } + } + }, + "turnBudgets": { + "type": "object", + "additionalProperties": false, + "required": ["claudeAuthorized", "claudeSpent", "codexAuthorized", "codexSpent"], + "properties": { + "claudeAuthorized": { "type": "integer", "minimum": 0 }, + "claudeSpent": { "type": "integer", "minimum": 0 }, + "codexAuthorized": { "type": "integer", "minimum": 0 }, + "codexSpent": { "type": "integer", "minimum": 0 } + } + }, + "matrix": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "result", "evidence"], + "properties": { + "id": { "type": "string", "pattern": "^RP[0-9]+$" }, + "result": { "enum": ["pass", "fail", "n/a"] }, + "evidence": { "type": "string" } + } + } + }, + "counters": { + "type": "object", + "additionalProperties": false, + "required": [ + "convergenceAttempts", + "admittedDeliveries", + "refusals", + "uncertain", + "duplicateDeliveries", + "wrongPaneDeliveries", + "busyAdmissions", + "manualActivityAdmissions", + "replays" + ], + "properties": { + "convergenceAttempts": { "type": "integer", "minimum": 0 }, + "admittedDeliveries": { "type": "integer", "minimum": 0 }, + "refusals": { "type": "integer", "minimum": 0 }, + "uncertain": { "type": "integer", "minimum": 0 }, + "duplicateDeliveries": { "type": "integer", "minimum": 0 }, + "wrongPaneDeliveries": { "type": "integer", "minimum": 0 }, + "busyAdmissions": { "type": "integer", "minimum": 0 }, + "manualActivityAdmissions": { "type": "integer", "minimum": 0 }, + "replays": { "type": "integer", "minimum": 0 } + } + }, + "deliveries": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["messageHash", "byteCount"], + "properties": { + "messageHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "byteCount": { "type": "integer", "minimum": 0 } + } + } + }, + "restart": { + "type": "object", + "additionalProperties": false, + "required": ["queuedRestored", "uncertainAfterRestart", "completedRestored", "reExecutions"], + "properties": { + "queuedRestored": { "type": "integer", "minimum": 0 }, + "uncertainAfterRestart": { "type": "integer", "minimum": 0 }, + "completedRestored": { "type": "integer", "minimum": 0 }, + "reExecutions": { "type": "integer", "minimum": 0 } + } + }, + "cleanup": { + "type": "object", + "additionalProperties": false, + "required": ["storeRemoved", "messageFilesRemoved", "providerFilesUntouched"], + "properties": { + "storeRemoved": { "type": "boolean" }, + "messageFilesRemoved": { "type": "boolean" }, + "providerFilesUntouched": { "type": "boolean" } + } + } + }, + "definitions": { + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["verdict", "versionKnown"], + "properties": { + "verdict": { + "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "NOT_AUTHORIZED", "n/a"] + }, + "versionKnown": { "type": "boolean" }, + "identityHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + } +} diff --git a/packages/terminal-tmux/poc/repl/report.ts b/packages/terminal-tmux/poc/repl/report.ts new file mode 100644 index 000000000..e8f4c812a --- /dev/null +++ b/packages/terminal-tmux/poc/repl/report.ts @@ -0,0 +1,170 @@ +/** + * Issue #774 POC — the report artifact and its validator. + * + * The POC's result is one `terminal-repl-poc-report.v1.json`, validated against + * the checked-in schema beside this file. It carries hashes, counters, versions, + * turn budgets, the RP1–RP18 matrix, and restart and cleanup evidence — and + * nothing that could leak a conversation: no transcript text, assistant reply, + * path, argv, environment, tmux identifier, credential, socket, token or raw + * native identity. An identity is carried only as a hash. + * + * The schema is the disclosure boundary. A report that tried to carry a + * forbidden field would fail validation here, so the builder and the validator + * are kept together. + */ + +import { Ajv } from "ajv"; +import type { ErrorObject } from "ajv"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { until } from "effection"; +import type { Operation } from "effection"; + +export const REPORT_SCHEMA = "terminal-repl-poc-report.v1" as const; + +export type ReportVerdict = + | "PASS" + | "VIEW_ONLY" + | "PROVIDER_EXCLUDED" + | "ENVIRONMENT_BLOCKED" + | "HARNESS_FAILED" + | "NOT_AUTHORIZED"; + +export type ProviderVerdict = "PASS" | "VIEW_ONLY" | "PROVIDER_EXCLUDED" | "NOT_AUTHORIZED" | "n/a"; + +export type ReportMode = "deterministic" | "live-claude" | "live-codex"; + +export interface MatrixEntry { + readonly id: string; + readonly result: "pass" | "fail" | "n/a"; + readonly evidence: string; +} + +export interface ReportCounters { + readonly convergenceAttempts: number; + readonly admittedDeliveries: number; + readonly refusals: number; + readonly uncertain: number; + readonly duplicateDeliveries: number; + readonly wrongPaneDeliveries: number; + readonly busyAdmissions: number; + readonly manualActivityAdmissions: number; + readonly replays: number; +} + +export interface ProviderReport { + readonly verdict: ProviderVerdict; + readonly versionKnown: boolean; + readonly identityHash?: string; +} + +export interface DeliveryEvidence { + readonly messageHash: string; + readonly byteCount: number; +} + +export interface RestartEvidence { + readonly queuedRestored: number; + readonly uncertainAfterRestart: number; + readonly completedRestored: number; + readonly reExecutions: number; +} + +export interface CleanupEvidence { + readonly storeRemoved: boolean; + readonly messageFilesRemoved: boolean; + readonly providerFilesUntouched: boolean; +} + +export interface TurnBudgets { + readonly claudeAuthorized: number; + readonly claudeSpent: number; + readonly codexAuthorized: number; + readonly codexSpent: number; +} + +export interface TerminalReplReport { + readonly schema: typeof REPORT_SCHEMA; + readonly verdict: ReportVerdict; + readonly mode: ReportMode; + readonly runtime: string; + readonly detail?: string; + readonly base: { readonly sha: string; readonly parent?: string }; + readonly head?: { readonly sha?: string }; + readonly providers: { readonly claude: ProviderReport; readonly codex: ProviderReport }; + readonly turnBudgets: TurnBudgets; + readonly matrix: readonly MatrixEntry[]; + readonly counters: ReportCounters; + readonly deliveries?: readonly DeliveryEvidence[]; + readonly restart: RestartEvidence; + readonly cleanup: CleanupEvidence; +} + +/** Hash an identity so the report carries it without carrying the raw value. */ +export function identityHash(identity: string): string { + return createHash("sha256").update(identity, "utf8").digest("hex"); +} + +/** The result of validating a report against the checked-in schema. */ +export type Validation = + | { readonly valid: true } + | { readonly valid: false; readonly errors: readonly string[] }; + +/** Validate a report against `report.schema.json`, read from disk. */ +export function validateReport(report: unknown): Operation { + return (function* (): Operation { + const schemaPath = fileURLToPath(new URL("./report.schema.json", import.meta.url)); + const schemaText = new TextDecoder().decode(yield* until(readFile(schemaPath))); + const schema: unknown = JSON.parse(schemaText); + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(schema as object); + if (validate(report)) { + return { valid: true }; + } + const errors = (validate.errors ?? []).map( + (error: ErrorObject) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`, + ); + return { valid: false, errors }; + })(); +} + +/** The report a run produces when its live gates were not supplied. */ +export function notAuthorizedReport( + mode: ReportMode, + runtime: string, + base: string, +): TerminalReplReport { + return { + schema: REPORT_SCHEMA, + verdict: "NOT_AUTHORIZED", + mode, + runtime, + detail: "the live proof gates were not supplied, so no agent was started and no turn was spent", + base: { sha: base }, + providers: { + claude: { verdict: "NOT_AUTHORIZED", versionKnown: false }, + codex: { verdict: "NOT_AUTHORIZED", versionKnown: false }, + }, + turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, + matrix: [], + counters: zeroCounters(), + restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, + cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + }; +} + +/** A counters block with every field at zero. */ +export function zeroCounters(): ReportCounters { + return { + convergenceAttempts: 0, + admittedDeliveries: 0, + refusals: 0, + uncertain: 0, + duplicateDeliveries: 0, + wrongPaneDeliveries: 0, + busyAdmissions: 0, + manualActivityAdmissions: 0, + replays: 0, + }; +} diff --git a/packages/terminal-tmux/poc/repl/state.ts b/packages/terminal-tmux/poc/repl/state.ts new file mode 100644 index 000000000..42bd59c42 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/state.ts @@ -0,0 +1,282 @@ +/** + * Issue #774 POC — the immutable REPL state and its reducer. + * + * A disposable proof, not a production surface. Nothing here is exported from + * the package: it lives under `poc/repl/` and is reached only by the + * deterministic evidence in `packages/terminal-tmux/tests/repl-poc.test.ts` and + * by the gated live supervisor beside it. + * + * One Flux-style store holds this shape. Actions are the only way it changes, + * and the reducer here is the only place a transition is written. Observers, + * convergence monitors and the delivery worker dispatch actions; none of them + * mutates a role directly. Every reducer returns a fresh value rather than + * editing its input, so a persisted action history replays to exactly the state + * the live run held. + * + * There is no generic `delivered` message state. Terminal input does not prove + * delivery, so a message that was pasted sits at `attempt-started` until the + * provider's own session file records the exact user event — or becomes + * `uncertain` when that evidence never arrives. + */ + +import type { ReplAction } from "./actions.ts"; + +export const STATE_SCHEMA = "terminal-repl-poc-state.v1" as const; + +/** Which coding agent a role drives. The POC supports exactly these two. */ +export type Provider = "claude" | "codex"; + +/** + * What terminal convergence has established about a pane, independent of any + * provider evidence. + * + * `ready` is only an authorization to attempt an input; it never means a + * message was accepted. + */ +export type Readiness = "unknown" | "converging" | "ready" | "busy" | "unavailable"; + +/** + * The lifecycle of one REPL message. + * + * `attempt-started` is the durable record written *before* any terminal byte is + * sent, so a restart that finds it treats the outcome as `uncertain` rather than + * pasting again. + */ +export type MessageState = + | "queued" + | "converging" + | "attempt-started" + | "accepted" + | "completed" + | "refused" + | "uncertain"; + +/** The exact provider-native identity the isolated launch journal retained. */ +export interface NativeIdentity { + readonly provider: Provider; + /** The provider's own session identifier, matched exactly and never inferred. */ + readonly id: string; +} + +/** One normalized provider event, keyed by file identity and byte range. */ +export interface NormalizedEvent { + readonly kind: "user-accepted" | "assistant-output" | "turn-completed"; + /** Derived from the source file identity and the record's byte range. */ + readonly key: string; + /** The native identity the record belongs to, carried for cross-checking. */ + readonly identity: string; + /** The relevant text, or the empty string for a completion boundary. */ + readonly text: string; +} + +/** One REPL message and everything the store retains about it. */ +export interface ReplMessage { + readonly id: string; + /** Queue order within its role, so the head is unambiguous. */ + readonly seq: number; + /** The literal bytes to deliver. Never rendered into the report. */ + readonly text: string; + /** A harmless unique marker the exact user event must carry back. */ + readonly marker: string; + readonly state: MessageState; + /** How many times an attempt has been started for this message. */ + readonly attempts: number; +} + +/** One role's whole immutable slice of the store. */ +export interface RoleState { + readonly key: string; + /** The authored role presentation value, e.g. "Implementor". */ + readonly role: string; + /** The current issue presentation value. */ + readonly issue: string; + readonly identity: NativeIdentity; + /** Bumped when a pane is replaced; an old generation authorizes nothing. */ + readonly paneGeneration: number; + /** The located source's opaque identity, or "" before the observer locates one. */ + readonly observerSource: string; + /** The durable observer cursor: a byte offset that only ever advances. */ + readonly cursor: number; + readonly readiness: Readiness; + /** At most one message is in flight per pane; its id, or undefined. */ + readonly inFlight: string | undefined; + /** Queued, admitted and settled messages in order. */ + readonly messages: readonly ReplMessage[]; + /** The normalized provider events observed for this role, in order. */ + readonly events: readonly NormalizedEvent[]; +} + +/** The whole retained state of one REPL session. */ +export interface ReplState { + readonly schema: typeof STATE_SCHEMA; + /** The REPL's own retained XMD session identity. */ + readonly replSession: string; + readonly roles: Readonly>; + /** The sequence number the next dispatched action will carry. */ + readonly nextAction: number; +} + +/** The empty state a fresh store begins from. */ +export function emptyState(): ReplState { + return { schema: STATE_SCHEMA, replSession: "", roles: {}, nextAction: 0 }; +} + +/** Replace one role, leaving the rest of the map untouched. */ +function withRole(state: ReplState, key: string, role: RoleState): ReplState { + return { ...state, roles: { ...state.roles, [key]: role } }; +} + +/** Map one message by id, leaving the others as they are. */ +function mapMessage( + role: RoleState, + id: string, + change: (message: ReplMessage) => ReplMessage, +): RoleState { + const messages = role.messages.map((message) => (message.id === id ? change(message) : message)); + return { ...role, messages }; +} + +/** The head of the queue: the earliest message still `queued`, or undefined. */ +export function queueHead(role: RoleState): ReplMessage | undefined { + return role.messages.find((message) => message.state === "queued"); +} + +/** + * Fold one action into the state. + * + * The single place a transition is written. Every branch returns a fresh value; + * an action naming a role the state does not hold is ignored rather than + * throwing, because a replayed history is trusted to be well formed and a live + * dispatch validates the role before it is sent. + */ +export function reduce(state: ReplState, action: ReplAction): ReplState { + switch (action.type) { + case "ReplOpened": + return { ...state, replSession: action.replSession }; + case "RoleBound": { + const role: RoleState = { + key: action.key, + role: action.role, + issue: action.issue, + identity: action.identity, + paneGeneration: action.paneGeneration, + observerSource: "", + cursor: 0, + readiness: "unknown", + inFlight: undefined, + messages: [], + events: [], + }; + return withRole(state, action.key, role); + } + case "ReplClosed": + return state; + default: + return reduceRole(state, action); + } +} + +/** Every action that names an existing role folds through here. */ +function reduceRole(state: ReplState, action: RoleAction): ReplState { + const role = state.roles[action.key]; + if (role === undefined) { + return state; + } + return withRole(state, action.key, reduceOne(role, action)); +} + +/** Actions that carry a role key, excluding the one that creates the role. */ +type RoleAction = Exclude, { type: "RoleBound" }>; + +function reduceOne(role: RoleState, action: RoleAction): RoleState { + switch (action.type) { + case "MessageQueued": { + const message: ReplMessage = { + id: action.id, + seq: role.messages.length, + text: action.text, + marker: action.marker, + state: "queued", + attempts: 0, + }; + return { ...role, messages: [...role.messages, message] }; + } + case "TerminalObserved": + return { ...role, readiness: action.readiness }; + case "ProviderBusy": + return { ...role, readiness: "busy" }; + case "ProviderIdle": + return role.readiness === "busy" ? { ...role, readiness: "ready" } : role; + case "ConvergenceStarted": + return mapMessage({ ...role, readiness: "converging" }, action.id, (message) => ({ + ...message, + state: "converging", + })); + case "ConvergenceInvalidated": + return mapMessage(role, action.id, (message) => + message.state === "converging" ? { ...message, state: "queued" } : message, + ); + case "AttemptStarted": + return mapMessage({ ...role, inFlight: action.id }, action.id, (message) => ({ + ...message, + state: "attempt-started", + attempts: message.attempts + 1, + })); + case "AttemptDeclined": + return mapMessage({ ...role, inFlight: undefined }, action.id, (message) => + message.state === "attempt-started" || message.state === "converging" + ? { ...message, state: "queued" } + : message, + ); + case "AttemptUncertain": + return mapMessage({ ...role, inFlight: undefined }, action.id, (message) => ({ + ...message, + state: "uncertain", + })); + case "UserAccepted": + // Acceptance resolves an attempt in flight and also an attempt a restart + // left uncertain: a later exact user event under the intended identity is + // allowed to settle that uncertainty. + return recordEvent( + mapMessage(role, action.id, (message) => + message.state === "attempt-started" || message.state === "uncertain" + ? { ...message, state: "accepted" } + : message, + ), + { + kind: "user-accepted", + key: action.eventKey, + identity: action.identity, + text: action.text, + }, + ); + case "AssistantObserved": + return recordEvent(role, { + kind: "assistant-output", + key: action.eventKey, + identity: action.identity, + text: action.text, + }); + case "AssistantCompleted": + return recordEvent( + mapMessage({ ...role, inFlight: undefined }, action.id, (message) => + message.state === "accepted" ? { ...message, state: "completed" } : message, + ), + { kind: "turn-completed", key: action.eventKey, identity: action.identity, text: "" }, + ); + case "ObserverAdvanced": + return { ...role, cursor: action.cursor, observerSource: action.source }; + case "PaneUnavailable": + return { ...role, readiness: "unavailable" }; + case "ObserverRefused": + return role; + } +} + +/** Append a normalized event unless one with the same key is already present. */ +function recordEvent(role: RoleState, event: NormalizedEvent): RoleState { + if (role.events.some((existing) => existing.key === event.key)) { + return role; + } + return { ...role, events: [...role.events, event] }; +} diff --git a/packages/terminal-tmux/poc/repl/store.ts b/packages/terminal-tmux/poc/repl/store.ts new file mode 100644 index 000000000..6169a0f86 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/store.ts @@ -0,0 +1,162 @@ +/** + * Issue #774 POC — the sequence-numbered action store. + * + * A Flux-style store: one immutable state, one reducer, and an append-only log + * of the actions that produced it. Every accepted action is persisted as its own + * file, named by its sequence number, through a staged write and a rename so a + * crash never leaves a half-written record in the log. The directory is mode + * `0700` and each record `0600`. + * + * Restart restores the store by replaying that log. The log is required to be + * contiguous from zero with no duplicate and no malformed record; anything else + * is a conflicting history and is refused rather than read past, because a store + * that guessed at a gap would resume a run it cannot actually account for. + * + * StarFX was evaluated as an implementation aid and deliberately not adopted: + * the reducer and the log are small enough to own directly, and the POC must add + * no production dependency. + */ + +import { createSignal, ensure, resource } from "effection"; +import type { Operation, Stream } from "effection"; +import { ensureDir, exists, readdir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { chmod, rename } from "node:fs/promises"; +import { join } from "node:path"; +import { until } from "effection"; +import { ACTION_TYPES } from "./actions.ts"; +import type { ReplAction } from "./actions.ts"; +import { emptyState, reduce } from "./state.ts"; +import type { ReplState } from "./state.ts"; + +/** One persisted log entry: the action and the sequence number it was given. */ +export interface StoredAction { + readonly seq: number; + readonly action: ReplAction; +} + +/** A retained history that cannot be trusted to replay. */ +export class ReplStoreError extends Error { + override name = "ReplStoreError"; + constructor(reason: string) { + super(`the REPL POC store could not be restored: ${reason}`); + } +} + +/** The live handle a controller and its observers dispatch through. */ +export interface ReplStore { + /** The current immutable state. */ + state(): ReplState; + /** The whole retained log, in order. */ + history(): readonly StoredAction[]; + /** Fold one action in, persist it, and publish the new state. */ + dispatch(action: ReplAction): Operation; + /** Every state the store has published, for a consumer that watches it. */ + readonly states: Stream; +} + +const ACTION_TYPE_SET: ReadonlySet = new Set(ACTION_TYPES); + +/** A stored entry's file name: zero-padded so a lexical sort is numeric. */ +function recordName(seq: number): string { + return `${String(seq).padStart(6, "0")}.json`; +} + +/** + * Open one REPL store rooted at `dir`, restoring any retained log. + * + * The directory is created `0700`. A log already present is replayed to rebuild + * the state and the next sequence number; an absent directory is a fresh store. + */ +export function useReplStore(dir: string): Operation { + return resource(function* (provide) { + yield* ensureDir(dir); + yield* until(chmod(dir, 0o700)); + + const log: StoredAction[] = yield* loadLog(dir); + let current = emptyState(); + for (const entry of log) { + current = reduce(current, entry.action); + } + current = { ...current, nextAction: log.length }; + + const published = createSignal(); + yield* ensure(() => published.close()); + + function* dispatch(action: ReplAction): Operation { + const seq = current.nextAction; + const entry: StoredAction = { seq, action }; + yield* persist(dir, entry); + log.push(entry); + current = { ...reduce(current, action), nextAction: seq + 1 }; + published.send(current); + return current; + } + + yield* provide({ + state: () => current, + history: () => [...log], + dispatch, + states: published, + }); + }); +} + +/** Read, validate and order the retained log. */ +function* loadLog(dir: string): Operation { + if (!(yield* exists(dir))) { + return []; + } + const names = (yield* readdir(dir)).filter((name) => name.endsWith(".json")); + const entries: StoredAction[] = []; + for (const name of names) { + const text = yield* readTextFile(join(dir, name)); + entries.push(parseEntry(text, name)); + } + entries.sort((left, right) => left.seq - right.seq); + for (const [index, entry] of entries.entries()) { + if (entry.seq !== index) { + throw new ReplStoreError( + entry.seq < index + ? `a duplicate or out-of-order record at sequence ${entry.seq}` + : `a gap before sequence ${entry.seq}`, + ); + } + } + return entries; +} + +/** Parse one record strictly, refusing a shape the log may not contain. */ +function parseEntry(text: string, name: string): StoredAction { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new ReplStoreError(`a record that is not JSON (${name})`); + } + if (!isRecord(value) || typeof value.seq !== "number" || !Number.isInteger(value.seq)) { + throw new ReplStoreError(`a record with no integer sequence (${name})`); + } + const action = value.action; + if (!isRecord(action) || typeof action.type !== "string" || !ACTION_TYPE_SET.has(action.type)) { + throw new ReplStoreError(`a record with no known action type (${name})`); + } + return { seq: value.seq, action: action as unknown as ReplAction }; +} + +/** Write one record through a staged file and a rename, at mode `0600`. */ +function* persist(dir: string, entry: StoredAction): Operation { + const staged = join(dir, `${recordName(entry.seq)}.staged`); + const final = join(dir, recordName(entry.seq)); + yield* writeTextFile(staged, `${JSON.stringify(entry)}\n`); + yield* until(chmod(staged, 0o600)); + yield* until(rename(staged, final)); +} + +/** Remove one store's whole directory. For a POC harness cleaning up after itself. */ +export function purgeStore(dir: string): Operation { + return rm(dir, { recursive: true, force: true }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts new file mode 100644 index 000000000..ae30b2672 --- /dev/null +++ b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts @@ -0,0 +1,310 @@ +/** + * Issue #774 POC — the deterministic fake pane and synthetic session files. + * + * The fake pane implements the same `PaneProbe` the tmux provider would, over a + * structural state a test controls directly: a generation, a process, a + * terminal, a mode, client-activity and output-event counters, and an event + * epoch. It also holds two facts the real provider could never expose — whether + * the pane is *actually* busy and whether a person is *actually* typing — and + * those are readable only by the assertions, never by the convergence algorithm. + * If the algorithm ever pastes while either is true, the fake records it and the + * test fails. + * + * The synthetic-file helpers write append-only provider records in the exact + * shapes the two observers accept, so a test can build acceptance, completion, a + * partial tail, truncation, rotation, an ambiguous identity, a wrong identity and + * an unsupported shape without a real agent. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; +import { appendFile, readFile, rename, truncate, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { + GuardOutcome, + PaneProbe, + PaneSnapshot, + PasteRequest, +} from "../../../poc/repl/convergence.ts"; +import { structurallyEqual } from "../../../poc/repl/convergence.ts"; + +/** One paste the fake actually performed, with the hidden truth at that instant. */ +export interface FakeDelivery { + readonly buffer: string; + readonly bytes: string; + /** True only if a paste happened while the pane was actually busy. Must never be. */ + readonly whileBusy: boolean; + /** True only if a paste happened while a person was actually typing. Must never be. */ + readonly whileManual: boolean; +} + +/** The fake pane: a `PaneProbe` plus the controls and ground truth a test reads. */ +export interface FakePane { + readonly probe: PaneProbe; + /** Every paste that reached the pane, in order. */ + readonly deliveries: readonly FakeDelivery[]; + /** How many times the guard declined a paste. */ + readonly declines: number; + /** Bump the event epoch: any observable pane event. */ + event(): void; + /** A visible client acted (a person moved the cursor, scrolled, typed). */ + clientActivity(): void; + /** The pane produced output. */ + output(): void; + /** Set the hidden truth that the pane is busy on a turn. */ + setBusy(busy: boolean): void; + /** Set the hidden truth that a person is typing. */ + setManual(active: boolean): void; + /** Put the pane into (or out of) a mode such as copy-mode. */ + setMode(mode: string): void; + /** Replace the pane: a new generation at the same ordinal. */ + replace(): void; + /** The pane's process exited. */ + kill(): void; + /** Run `mutate` the next time the fake crosses a barrier. */ + armBarrier(mutate: () => void): void; + /** Run `mutate` the next time a buffer is loaded (just before the guarded paste). */ + armLoad(mutate: () => void): void; +} + +/** Options for a fresh fake pane. */ +export interface FakePaneOptions { + readonly generation?: number; + readonly bracketedPasteSupported?: boolean; +} + +/** Build a fake pane in an idle, usable state. */ +export function createFakePane(options: FakePaneOptions = {}): FakePane { + let generation = options.generation ?? 1; + let pid = 4321; + let terminal = "ttys021"; + let alive = true; + let mode = ""; + let foregroundProcess = 4321; + let clientActivityCount = 0; + let outputEvents = 0; + let epoch = 0; + let busy = false; + let manual = false; + let declines = 0; + const deliveries: FakeDelivery[] = []; + const buffers = new Map(); + let barrierTrap: (() => void) | undefined; + let loadTrap: (() => void) | undefined; + + function snapshot(): PaneSnapshot { + return { + generation, + pid: alive ? pid : -1, + terminal: alive ? terminal : "", + alive, + mode, + foregroundProcess, + clientActivity: clientActivityCount, + outputEvents, + epoch, + }; + } + + const probe: PaneProbe = { + // deno-lint-ignore require-yield + *snapshot(): Operation { + return snapshot(); + }, + *barrier(): Operation { + const trap = barrierTrap; + barrierTrap = undefined; + if (trap !== undefined) { + trap(); + } + // A barrier is an acknowledged round-trip; the yield models that wait + // without changing any structural fact by itself. + yield* until(Promise.resolve()); + }, + *loadBuffer(buffer, path): Operation { + const trap = loadTrap; + loadTrap = undefined; + if (trap !== undefined) { + trap(); + } + const bytes = new TextDecoder().decode(yield* until(readFile(path))); + buffers.set(buffer, bytes); + }, + // deno-lint-ignore require-yield + *guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation { + // The recheck and the paste happen with no suspension between them: the + // current state is read and compared, and a matching guard pastes at once. + const current = snapshot(); + if (!structurallyEqual(guard, current) || guard.epoch !== current.epoch) { + declines += 1; + return { outcome: "declined", reason: "guard-changed" }; + } + if (!current.alive) { + declines += 1; + return { outcome: "declined", reason: "pane-unavailable" }; + } + const bytes = buffers.get(delivery.buffer) ?? ""; + deliveries.push({ buffer: delivery.buffer, bytes, whileBusy: busy, whileManual: manual }); + return { outcome: "pasted" }; + }, + }; + + return { + probe, + get deliveries() { + return deliveries; + }, + get declines() { + return declines; + }, + event() { + epoch += 1; + }, + clientActivity() { + clientActivityCount += 1; + epoch += 1; + }, + output() { + outputEvents += 1; + epoch += 1; + }, + setBusy(value) { + busy = value; + }, + setManual(value) { + manual = value; + }, + setMode(value) { + mode = value; + epoch += 1; + }, + replace() { + generation += 1; + pid += 1; + terminal = `ttys0${20 + generation}`; + epoch += 1; + }, + kill() { + alive = false; + epoch += 1; + }, + armBarrier(mutate) { + barrierTrap = mutate; + }, + armLoad(mutate) { + loadTrap = mutate; + }, + }; +} + +// --- Synthetic provider records ------------------------------------------------ + +/** A Claude `user` record carrying the exact attempted text under `sessionId`. */ +export function claudeUser(sessionId: string, text: string): string { + return line({ + type: "user", + sessionId, + message: { role: "user", content: [{ type: "text", text }] }, + }); +} + +/** A Claude `assistant` record. */ +export function claudeAssistant(sessionId: string, text: string): string { + return line({ + type: "assistant", + sessionId, + message: { role: "assistant", content: [{ type: "text", text }] }, + }); +} + +/** The explicit Claude completion boundary. */ +export function claudeResult(sessionId: string): string { + return line({ type: "result", sessionId, subtype: "success" }); +} + +/** A Claude `user` record whose message has no readable text: an unsupported shape. */ +export function claudeUnsupported(sessionId: string): string { + return line({ type: "user", sessionId, message: { role: "user" } }); +} + +/** The Codex `session_meta` header naming the thread identity. */ +export function codexMeta(id: string): string { + return line({ type: "session_meta", payload: { id } }); +} + +/** A Codex `user_message` event carrying the exact attempted text. */ +export function codexUser(text: string): string { + return line({ type: "event_msg", payload: { type: "user_message", message: text } }); +} + +/** A Codex `agent_message` event. */ +export function codexAgent(text: string): string { + return line({ type: "event_msg", payload: { type: "agent_message", message: text } }); +} + +/** The Codex completion boundary. */ +export function codexComplete(): string { + return line({ type: "event_msg", payload: { type: "task_complete" } }); +} + +/** A Codex `user_message` with no message text: an unsupported shape. */ +export function codexUnsupported(): string { + return line({ type: "event_msg", payload: { type: "user_message" } }); +} + +/** One newline-terminated JSON record. */ +function line(record: unknown): string { + return `${JSON.stringify(record)}\n`; +} + +/** Write the given records to a file, replacing whatever was there. */ +export function writeRecords(path: string, records: readonly string[]): Operation { + return (function* (): Operation { + yield* until(writeFile(path, records.join(""), "utf8")); + })(); +} + +/** Append records to a file, as a provider appends to its own session file. */ +export function appendRecords(path: string, records: readonly string[]): Operation { + return (function* (): Operation { + yield* until(appendFile(path, records.join(""), "utf8")); + })(); +} + +/** Append a partial (unterminated) record fragment, as a mid-write file has. */ +export function appendPartial(path: string, fragment: string): Operation { + return (function* (): Operation { + yield* until(appendFile(path, fragment, "utf8")); + })(); +} + +/** Truncate a file to `bytes`, modelling a provider file cut short. */ +export function truncateFile(path: string, bytes: number): Operation { + return (function* (): Operation { + yield* until(truncate(path, bytes)); + })(); +} + +/** Replace a file with a fresh one at a new inode, modelling rotation. */ +export function rotateFile(path: string, records: readonly string[]): Operation { + return (function* (): Operation { + const staged = `${path}.rotated`; + yield* until(writeFile(staged, records.join(""), "utf8")); + yield* until(rename(staged, path)); + })(); +} + +/** The byte length of a set of records, for a truncation offset. */ +export function byteLength(records: readonly string[]): number { + return new TextEncoder().encode(records.join("")).length; +} + +/** A file path inside a provider directory named for a Claude session. */ +export function claudeSessionPath(directory: string, sessionId: string): string { + return join(directory, `${sessionId}.jsonl`); +} + +/** A file path inside a provider directory for a Codex rollout. */ +export function codexRolloutPath(directory: string, label: string): string { + return join(directory, `rollout-${label}.jsonl`); +} diff --git a/packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-claude.jsonl b/packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-claude.jsonl new file mode 100644 index 000000000..27e411a2e --- /dev/null +++ b/packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-claude.jsonl @@ -0,0 +1,4 @@ +{"type":"summary","summary":"a session that has nothing to do with acceptance"} +{"type":"user","sessionId":"claude-fixture-1","message":{"role":"user","content":[{"type":"text","text":"Ship the accepted plan"}]}} +{"type":"assistant","sessionId":"claude-fixture-1","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]}} +{"type":"result","sessionId":"claude-fixture-1","subtype":"success"} diff --git a/packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-codex.jsonl b/packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-codex.jsonl new file mode 100644 index 000000000..c5ab837b1 --- /dev/null +++ b/packages/terminal-tmux/tests/fixtures/repl-poc/synthetic-codex.jsonl @@ -0,0 +1,4 @@ +{"type":"session_meta","payload":{"id":"codex-fixture-1"}} +{"type":"event_msg","payload":{"type":"user_message","message":"Review the diff"}} +{"type":"event_msg","payload":{"type":"agent_message","message":"Looks good."}} +{"type":"event_msg","payload":{"type":"task_complete"}} diff --git a/packages/terminal-tmux/tests/repl-poc.test.ts b/packages/terminal-tmux/tests/repl-poc.test.ts new file mode 100644 index 000000000..d1957b44d --- /dev/null +++ b/packages/terminal-tmux/tests/repl-poc.test.ts @@ -0,0 +1,1031 @@ +/** + * Issue #774 POC — the deterministic evidence for black-box REPL messaging. + * + * This suite freezes RP1–RP18 from the plan and proves them without a real agent + * or a real tmux. A fake pane supplies the structural convergence facts the + * algorithm reads, and synthetic append-only files supply the provider evidence + * the observer reads. The fake also holds the hidden truth — actually busy, + * actually typed-into — that only these assertions see, so a paste admitted while + * either was true is caught. + * + * Every success is a parsed record, an explicit event, or a counted delivery; + * elapsed time proves nothing here. The final row builds the + * `terminal-repl-poc-report.v1` artifact and validates it against the checked-in + * schema. + * + * The whole suite is portable: it uses no tmux, no CLI subprocess, and no + * runtime-specific API, so it runs under Deno, Node and Bun like any other file. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { ensureDir, exists, readTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { + attemptStep, + observeStep, + reconcileRestart, + settleUnconfirmed, +} from "../poc/repl/controller.ts"; +import type { DeliveryOptions, ObserverSource } from "../poc/repl/controller.ts"; +import { purgeStore, useReplStore } from "../poc/repl/store.ts"; +import type { ReplStore } from "../poc/repl/store.ts"; +import { claudeParser } from "../poc/repl/claude-observer.ts"; +import { codexParser } from "../poc/repl/codex-observer.ts"; +import type { Provider, ReplState } from "../poc/repl/state.ts"; +import type { ProviderParser } from "../poc/repl/observer.ts"; +import { identityHash, REPORT_SCHEMA, validateReport, zeroCounters } from "../poc/repl/report.ts"; +import type { + DeliveryEvidence, + MatrixEntry, + ReportCounters, + RestartEvidence, +} from "../poc/repl/report.ts"; +import { + appendPartial, + appendRecords, + claudeSessionPath, + claudeUnsupported, + codexRolloutPath, + codexUnsupported, + createFakePane, + rotateFile, + truncateFile, + writeRecords, +} from "./fixtures/repl-poc/fake-terminal.ts"; +import type { FakePane } from "./fixtures/repl-poc/fake-terminal.ts"; +import { + claudeAssistant, + claudeResult, + claudeUser, + codexAgent, + codexComplete, + codexMeta, + codexUser, +} from "./fixtures/repl-poc/fake-terminal.ts"; + +/** The exact base the POC was implemented from, recorded in the report. */ +const BASE_SHA = "97fda6aa7b5f85db747c066898fd3ef3c6d1dbeb"; + +/** One provider's record shapes, so a scenario can run against either agent. */ +interface ProviderKit { + readonly provider: Provider; + readonly parser: ProviderParser; + path(directory: string, id: string): string; + idle(id: string): string[]; + user(id: string, text: string): string; + assistant(id: string, text: string): string; + complete(id: string): string; + unsupported(id: string): string; +} + +const CLAUDE_KIT: ProviderKit = { + provider: "claude", + parser: claudeParser, + path: (directory, id) => claudeSessionPath(directory, id), + idle: () => [], + user: (id, text) => claudeUser(id, text), + assistant: (id, text) => claudeAssistant(id, text), + complete: (id) => claudeResult(id), + unsupported: (id) => claudeUnsupported(id), +}; + +const CODEX_KIT: ProviderKit = { + provider: "codex", + parser: codexParser, + path: (directory) => codexRolloutPath(directory, "main"), + idle: (id) => [codexMeta(id)], + user: (_id, text) => codexUser(text), + assistant: (_id, text) => codexAgent(text), + complete: () => codexComplete(), + unsupported: () => codexUnsupported(), +}; + +/** Everything one scenario works against. */ +interface Bag { + readonly kit: ProviderKit; + readonly storeDir: string; + readonly providerDir: string; + readonly messageDir: string; + readonly store: ReplStore; + readonly pane: FakePane; + readonly identity: { readonly provider: Provider; readonly id: string }; + readonly observer: ObserverSource; + readonly options: DeliveryOptions; + readonly path: string; +} + +/** The report the last row assembles from what the rows above recorded. */ +const matrix: MatrixEntry[] = []; +const tally: { -readonly [K in keyof ReportCounters]: ReportCounters[K] } = zeroCounters(); +const deliveries: DeliveryEvidence[] = []; +const restart: { -readonly [K in keyof RestartEvidence]: RestartEvidence[K] } = { + queuedRestored: 0, + uncertainAfterRestart: 0, + completedRestored: 0, + reExecutions: 0, +}; +const cleanup = { storeRemoved: false, messageFilesRemoved: false, providerFilesUntouched: false }; + +/** Record one RP outcome and return whether it passed, for a fluent assertion. */ +function record(id: string, pass: boolean, evidence: string): boolean { + matrix.push({ id, result: pass ? "pass" : "fail", evidence }); + return pass; +} + +/** Build one scenario's directories, store, fake pane and observer. */ +function scaffold( + kit: ProviderKit, + root: string, + options: { readonly id?: string; readonly generation?: number } = {}, +): Operation { + return (function* (): Operation { + const suffix = randomUUID().slice(0, 8); + const storeDir = join(root, `store-${suffix}`); + const providerDir = join(root, `provider-${suffix}`); + const messageDir = join(root, `messages-${suffix}`); + yield* ensureDir(providerDir); + yield* ensureDir(messageDir); + yield* until(chmod(messageDir, 0o700)); + const store = yield* useReplStore(storeDir); + const pane = createFakePane( + options.generation === undefined ? {} : { generation: options.generation }, + ); + const identity = { provider: kit.provider, id: options.id ?? `${kit.provider}-${suffix}` }; + return { + kit, + storeDir, + providerDir, + messageDir, + store, + pane, + identity, + observer: { parser: kit.parser, directory: providerDir }, + options: { messageDir, bracketedPaste: true, submitKey: "Enter" }, + path: kit.path(providerDir, identity.id), + }; + })(); +} + +/** Open the REPL and bind one role in an idle, located state. */ +function bind(bag: Bag, options: { readonly generation?: number } = {}): Operation { + return (function* (): Operation { + yield* bag.store.dispatch({ type: "ReplOpened", replSession: "repl-poc" }); + yield* bag.store.dispatch({ + type: "RoleBound", + key: bag.identity.id, + role: bag.kit.provider === "claude" ? "Implementor" : "Reviewer", + issue: "#774", + identity: bag.identity, + paneGeneration: options.generation ?? 1, + }); + yield* writeRecords(bag.path, bag.kit.idle(bag.identity.id)); + })(); +} + +/** Queue one message carrying a unique marker, and return its id and text. */ +function queue(bag: Bag): Operation<{ id: string; text: string; marker: string }> { + return (function* (): Operation<{ id: string; text: string; marker: string }> { + const marker = `MK-${randomUUID().slice(0, 8)}`; + const id = `msg-${randomUUID().slice(0, 8)}`; + const text = `Please pick up ${marker}\nand keep this second line intact`; + yield* bag.store.dispatch({ type: "MessageQueued", key: bag.identity.id, id, text, marker }); + return { id, text, marker }; + })(); +} + +/** The role slice, read fresh from the store. */ +function role(state: ReplState, key: string) { + const found = state.roles[key]; + if (found === undefined) { + throw new Error(`the store lost role ${key}`); + } + return found; +} + +/** The state of one message by id. */ +function messageState(store: ReplStore, key: string, id: string): string { + const message = role(store.state(), key).messages.find((entry) => entry.id === id); + return message === undefined ? "absent" : message.state; +} + +describe("issue #774 — black-box REPL messaging POC", () => { + it("RP1 — an idle pane accepts exactly one literal message and completes", function* () { + const root = yield* useTempDirectory("xmd-repl-rp1-"); + for (const kit of [CLAUDE_KIT, CODEX_KIT]) { + const bag = yield* scaffold(kit, root); + yield* bind(bag); + const message = yield* queue(bag); + + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("pasted"); + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + deliveries.push({ messageHash: attempt.hash, byteCount: attempt.byteCount }); + } + // Exactly one paste reached the pane, and not while busy or manual. + expect(bag.pane.deliveries.length).toEqual(1); + expect(bag.pane.deliveries[0]?.whileBusy).toEqual(false); + expect(bag.pane.deliveries[0]?.whileManual).toEqual(false); + + // The intended file records the exact user event and a complete assistant turn. + yield* appendRecords(bag.path, [ + kit.user(bag.identity.id, message.text), + kit.assistant(bag.identity.id, "Working on it."), + kit.complete(bag.identity.id), + ]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("completed"); + } + expect( + record("RP1", true, "one paste, exact user event, explicit completion, both providers"), + ).toEqual(true); + }); + + it("RP2 — a busy pane keeps the message queued until completion", function* () { + const root = yield* useTempDirectory("xmd-repl-rp2-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + + // The provider is mid-turn: a user event with no completion is an open turn. + yield* appendRecords(bag.path, [codexUser("someone else's turn"), codexAgent("thinking")]); + bag.pane.setBusy(true); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("not-ready"); + expect(bag.pane.deliveries.length).toEqual(0); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + + // The turn completes; the pane is idle; the message is admitted now. + yield* appendRecords(bag.path, [codexComplete()]); + bag.pane.setBusy(false); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + const admitted = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(admitted.outcome).toEqual("pasted"); + if (admitted.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + expect(bag.pane.deliveries.every((delivery) => !delivery.whileBusy)).toEqual(true); + expect( + record("RP2", true, "no admission during an open provider turn; admitted after completion"), + ).toEqual(true); + }); + + it("RP3 — Claude and Codex receive distinct messages with no cross-delivery", function* () { + const root = yield* useTempDirectory("xmd-repl-rp3-"); + const claude = yield* scaffold(CLAUDE_KIT, root); + const codex = yield* scaffold(CODEX_KIT, root); + yield* bind(claude); + yield* bind(codex); + const claudeMessage = yield* queue(claude); + const codexMessage = yield* queue(codex); + + const a = yield* attemptStep( + claude.store, + claude.identity.id, + claude.pane.probe, + claude.observer, + claude.options, + ); + const b = yield* attemptStep( + codex.store, + codex.identity.id, + codex.pane.probe, + codex.observer, + codex.options, + ); + tally.convergenceAttempts += 2; + expect(a.outcome).toEqual("pasted"); + expect(b.outcome).toEqual("pasted"); + if (a.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + if (b.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + + // Each file records only its own message. + yield* appendRecords(claude.path, [ + claude.kit.user(claude.identity.id, claudeMessage.text), + claude.kit.complete(claude.identity.id), + ]); + yield* appendRecords(codex.path, [ + codex.kit.user(codex.identity.id, codexMessage.text), + codex.kit.complete(codex.identity.id), + ]); + yield* observeStep(claude.store, claude.identity.id, claude.observer); + yield* observeStep(codex.store, codex.identity.id, codex.observer); + + const claudeDelivered = claude.pane.deliveries.map((delivery) => delivery.bytes); + const codexDelivered = codex.pane.deliveries.map((delivery) => delivery.bytes); + const crossed = + claudeDelivered.includes(codexMessage.text) || codexDelivered.includes(claudeMessage.text); + if (crossed) { + tally.wrongPaneDeliveries += 1; + } + expect(crossed).toEqual(false); + expect(messageState(claude.store, claude.identity.id, claudeMessage.id)).toEqual("completed"); + expect(messageState(codex.store, codex.identity.id, codexMessage.id)).toEqual("completed"); + expect(record("RP3", true, "distinct identities, distinct files, zero cross-delivery")).toEqual( + true, + ); + }); + + it("RP4 — back-to-back messages keep only one in flight", function* () { + const root = yield* useTempDirectory("xmd-repl-rp4-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const first = yield* queue(bag); + const second = yield* queue(bag); + + const one = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(one.outcome).toEqual("pasted"); + if (one.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + // The second cannot begin: a message is in flight. + const blocked = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(blocked.outcome).toEqual("skipped"); + expect(bag.pane.deliveries.length).toEqual(1); + + // The first completes; only then does the second go. + yield* appendRecords(bag.path, [codexUser(first.text), codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, first.id)).toEqual("completed"); + const two = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(two.outcome).toEqual("pasted"); + if (two.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + expect(bag.pane.deliveries.length).toEqual(2); + void second; + expect( + record( + "RP4", + true, + "one in flight at a time; the second admitted only after the first completed", + ), + ).toEqual(true); + }); + + it("RP5 — multiline, Unicode and shell-significant bytes arrive exactly", function* () { + const root = yield* useTempDirectory("xmd-repl-rp5-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const marker = `MK-${randomUUID().slice(0, 8)}`; + const id = `msg-${randomUUID().slice(0, 8)}`; + const text = [ + `First line with ${marker}`, + 'shell stuff: $HOME `whoami`; rm -rf / && echo "nope"', + "unicode: café — 日本語 — ✓ — 🙂", + "trailing backslash \\ and a quote ' and a semicolon ;", + ].join("\n"); + yield* bag.store.dispatch({ type: "MessageQueued", key: bag.identity.id, id, text, marker }); + + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("pasted"); + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + // The bytes the pane received are exactly the bytes queued — nothing was + // interpreted by a shell or a tmux argument vector. + expect(bag.pane.deliveries[0]?.bytes).toEqual(text); + + yield* appendRecords(bag.path, [codexUser(text), codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + // The provider event carries the exact bytes, so acceptance and completion settled. + expect(messageState(bag.store, bag.identity.id, id)).toEqual("completed"); + expect( + record( + "RP5", + true, + "delivered bytes byte-identical to the queued message, including LF, Unicode and metacharacters", + ), + ).toEqual(true); + }); + + it("RP6 — restart before delivery restores the queue and produces one attempt", function* () { + const root = yield* useTempDirectory("xmd-repl-rp6-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + + // Restart: a second store handle replays the persisted log. + const restarted = yield* useReplStore(bag.storeDir); + const settled = yield* reconcileRestart(restarted); + expect(settled).toEqual(0); + restart.queuedRestored += 1; + expect(messageState(restarted, bag.identity.id, message.id)).toEqual("queued"); + + const attempt = yield* attemptStep( + restarted, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + tally.replays += 1; + expect(attempt.outcome).toEqual("pasted"); + expect(bag.pane.deliveries.length).toEqual(1); + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + expect( + record("RP6", true, "queued message survived restart; exactly one attempt after"), + ).toEqual(true); + }); + + it("RP7 — restart during observation restores uncertain and never pastes again", function* () { + const root = yield* useTempDirectory("xmd-repl-rp7-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("pasted"); + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); + + // Restart after the attempt but before acceptance: the outcome is uncertain. + const restarted = yield* useReplStore(bag.storeDir); + const settled = yield* reconcileRestart(restarted); + expect(settled).toEqual(1); + restart.uncertainAfterRestart += 1; + tally.uncertain += 1; + expect(messageState(restarted, bag.identity.id, message.id)).toEqual("uncertain"); + + // A fresh pane for the restarted controller: it must not paste again. + const restartedPane = createFakePane(); + const again = yield* attemptStep( + restarted, + bag.identity.id, + restartedPane.probe, + bag.observer, + bag.options, + ); + tally.replays += 1; + expect(again.outcome).toEqual("skipped"); + expect(restartedPane.deliveries.length).toEqual(0); + + // A later exact user event resolves the uncertainty without a new paste. + yield* appendRecords(bag.path, [codexUser(message.text), codexComplete()]); + yield* observeStep(restarted, bag.identity.id, bag.observer); + expect(messageState(restarted, bag.identity.id, message.id)).toEqual("completed"); + expect(restartedPane.deliveries.length).toEqual(0); + expect( + record( + "RP7", + true, + "attempt-started restored as uncertain, not re-pasted; later exact event resolved it", + ), + ).toEqual(true); + }); + + it("RP8 — restart after completion restores completed state with no duplicate", function* () { + const root = yield* useTempDirectory("xmd-repl-rp8-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + yield* appendRecords(bag.path, [codexUser(message.text), codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("completed"); + const cursorBefore = role(bag.store.state(), bag.identity.id).cursor; + + const restarted = yield* useReplStore(bag.storeDir); + yield* reconcileRestart(restarted); + restart.completedRestored += 1; + const restartedPane = createFakePane(); + const again = yield* attemptStep( + restarted, + bag.identity.id, + restartedPane.probe, + bag.observer, + bag.options, + ); + tally.replays += 1; + expect(again.outcome).toEqual("skipped"); + expect(restartedPane.deliveries.length).toEqual(0); + expect(messageState(restarted, bag.identity.id, message.id)).toEqual("completed"); + expect(role(restarted.state(), bag.identity.id).cursor).toEqual(cursorBefore); + expect( + record( + "RP8", + true, + "completed message and cursor restored; no re-execution or duplicate event", + ), + ).toEqual(true); + }); + + it("RP9 — a partial record advances no cursor and emits once when completed", function* () { + const root = yield* useTempDirectory("xmd-repl-rp9-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + + // Consume the idle baseline first, so the cursor sits at the file's end. + yield* observeStep(bag.store, bag.identity.id, bag.observer); + const cursorBefore = role(bag.store.state(), bag.identity.id).cursor; + + // A half-written user record: no newline yet. The cursor must not move. + const full = codexUser(message.text); + const partial = full.slice(0, Math.floor(full.length / 2)); + yield* appendPartial(bag.path, partial); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(role(bag.store.state(), bag.identity.id).cursor).toEqual(cursorBefore); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); + + // Complete the record: it emits exactly once. + yield* appendPartial(bag.path, full.slice(partial.length)); + yield* appendRecords(bag.path, [codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("completed"); + const userEvents = role(bag.store.state(), bag.identity.id).events.filter( + (event) => event.kind === "user-accepted", + ); + expect(userEvents.length).toEqual(1); + expect( + record( + "RP9", + true, + "partial tail held the cursor; the completed record emitted exactly one user event", + ), + ).toEqual(true); + }); + + it("RP10 — manual activity before the guard invalidates the attempt with zero paste", function* () { + const root = yield* useTempDirectory("xmd-repl-rp10-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + // A person types between convergence and the final guard: the load hook + // fires just before the guarded paste and moves client activity. + bag.pane.armLoad(() => { + bag.pane.setManual(true); + bag.pane.clientActivity(); + }); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("declined"); + expect(bag.pane.deliveries.length).toEqual(0); + expect(bag.pane.declines).toEqual(1); + // The message is queued again, not lost. + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + expect( + record( + "RP10", + true, + "activity between convergence and the guard declined the paste; zero bytes sent", + ), + ).toEqual(true); + }); + + it("RP11 — a pane that exited before delivery leaves the message unattempted", function* () { + const root = yield* useTempDirectory("xmd-repl-rp11-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + bag.pane.kill(); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("not-ready"); + expect(bag.pane.deliveries.length).toEqual(0); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + expect(role(bag.store.state(), bag.identity.id).readiness).toEqual("unavailable"); + expect( + record( + "RP11", + true, + "a dead pane is refused; the message stays unattempted and the role unavailable", + ), + ).toEqual(true); + }); + + it("RP12 — a replacement pane at the same ordinal is refused", function* () { + const root = yield* useTempDirectory("xmd-repl-rp12-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag, { generation: 1 }); + const message = yield* queue(bag); + // The pane is replaced: a fresh generation the role never bound to. + bag.pane.replace(); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("not-ready"); + if (attempt.outcome === "not-ready") { + expect(attempt.reason).toEqual("pane-replaced"); + } + expect(bag.pane.deliveries.length).toEqual(0); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + expect( + record("RP12", true, "a bumped generation is not adopted; the old generation is refused"), + ).toEqual(true); + }); + + it("RP13 — an ambiguous identity refuses the observer and delivery", function* () { + const root = yield* useTempDirectory("xmd-repl-rp13-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + // A second rollout file names the same identity: now two files match. + const second = codexRolloutPath(bag.providerDir, "duplicate"); + yield* writeRecords(second, [codexMeta(bag.identity.id)]); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("refused"); + if (attempt.outcome === "refused") { + expect(attempt.refusal).toEqual("identity-ambiguous"); + } + tally.refusals += 1; + expect(bag.pane.deliveries.length).toEqual(0); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + expect( + record("RP13", true, "two files for one identity refused both observation and delivery"), + ).toEqual(true); + }); + + it("RP14 — truncation and rotation refuse observation without rewinding", function* () { + const root = yield* useTempDirectory("xmd-repl-rp14-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + yield* appendRecords(bag.path, [codexUser(message.text)]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + const cursorAfterAccept = role(bag.store.state(), bag.identity.id).cursor; + expect(cursorAfterAccept > 0).toEqual(true); + + // Truncate below the cursor: the observer refuses rather than rewinding. + yield* truncateFile(bag.path, cursorAfterAccept - 5); + const truncated = yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(truncated.outcome).toEqual("refused"); + if (truncated.outcome === "refused") { + expect(truncated.refusal).toEqual("truncation"); + } + tally.refusals += 1; + expect(role(bag.store.state(), bag.identity.id).cursor).toEqual(cursorAfterAccept); + + // Rotate the file to a new inode: a replacement is refused, not read. + yield* rotateFile(bag.path, [ + codexMeta(bag.identity.id), + codexUser(message.text), + codexComplete(), + ]); + const rotated = yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(rotated.outcome).toEqual("refused"); + if (rotated.outcome === "refused") { + expect(rotated.refusal).toEqual("rotation"); + } + tally.refusals += 1; + expect( + record("RP14", true, "truncation and rotation both refused; the cursor never rewound"), + ).toEqual(true); + }); + + it("RP15 — an unsupported relevant shape refuses rather than skipping", function* () { + const root = yield* useTempDirectory("xmd-repl-rp15-"); + for (const kit of [CLAUDE_KIT, CODEX_KIT]) { + const bag = yield* scaffold(kit, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + // A relevant record whose required member is missing is unsupported. + yield* appendRecords(bag.path, [kit.unsupported(bag.identity.id)]); + const observed = yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(observed.outcome).toEqual("refused"); + if (observed.outcome === "refused") { + expect(observed.refusal).toEqual("unsupported-shape"); + } + tally.refusals += 1; + // Nothing was accepted, and the cursor did not move past the bad record. + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); + } + expect( + record( + "RP15", + true, + "an unsupported relevant record refused, not skipped, for both providers", + ), + ).toEqual(true); + }); + + it("RP16 — an unconfirmed attempt becomes uncertain and is not retried", function* () { + const root = yield* useTempDirectory("xmd-repl-rp16-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("pasted"); + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + // No exact user event ever appears; a bounded observation shows nothing. + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); + const settled = yield* settleUnconfirmed(bag.store, bag.identity.id, "no-acceptance"); + expect(settled).toEqual(true); + tally.uncertain += 1; + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("uncertain"); + + // It is not retried: a further attempt step delivers nothing new. + const again = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(again.outcome).toEqual("skipped"); + expect(bag.pane.deliveries.length).toEqual(1); + expect( + record("RP16", true, "an unconfirmed paste became uncertain and was never retried"), + ).toEqual(true); + }); + + it("RP17 — wrong evidence never settles a message as accepted", function* () { + const root = yield* useTempDirectory("xmd-repl-rp17-"); + + // (a) A marker under another identity: the observer refuses it. + const other = yield* scaffold(CLAUDE_KIT, root); + yield* bind(other); + const otherMessage = yield* queue(other); + const attemptA = yield* attemptStep( + other.store, + other.identity.id, + other.pane.probe, + other.observer, + other.options, + ); + tally.convergenceAttempts += 1; + if (attemptA.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + // A user record under a different sessionId, in the intended file. + yield* appendRecords(other.path, [claudeUser("someone-else-entirely", otherMessage.text)]); + const refused = yield* observeStep(other.store, other.identity.id, other.observer); + expect(refused.outcome).toEqual("refused"); + if (refused.outcome === "refused") { + expect(refused.refusal).toEqual("identity-mismatch"); + } + tally.refusals += 1; + expect(messageState(other.store, other.identity.id, otherMessage.id)).toEqual( + "attempt-started", + ); + + // (b) Different text under the intended identity: no acceptance. + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attemptB = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + if (attemptB.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + yield* appendRecords(bag.path, [codexUser("a completely different message"), codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); + expect( + record( + "RP17", + true, + "a marker under another identity refused; different text under the intended identity did not accept", + ), + ).toEqual(true); + }); + + it("RP18 — ownership and cleanup remove only owned state and never touch provider files", function* () { + const root = yield* useTempDirectory("xmd-repl-rp18-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + if (attempt.outcome === "pasted") { + tally.admittedDeliveries += 1; + } + // The private message file is removed once the attempt settled. + const messageFile = join(bag.messageDir, `${message.id}.msg`); + cleanup.messageFilesRemoved = !(yield* exists(messageFile)); + + // Observing does not write: the provider file bytes are unchanged. + yield* appendRecords(bag.path, [codexUser(message.text), codexComplete()]); + const before = yield* readTextFile(bag.path); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + const after = yield* readTextFile(bag.path); + cleanup.providerFilesUntouched = before === after; + + // The store's own directory is removable; nothing outside it is swept. + yield* purgeStore(bag.storeDir); + cleanup.storeRemoved = !(yield* exists(bag.storeDir)); + expect(yield* exists(bag.path)).toEqual(true); + + expect(cleanup.messageFilesRemoved).toEqual(true); + expect(cleanup.providerFilesUntouched).toEqual(true); + expect(cleanup.storeRemoved).toEqual(true); + expect( + record( + "RP18", + true, + "message file removed, provider file byte-identical after observe, store dir purged, provider file survived", + ), + ).toEqual(true); + }); + + it("produces a valid terminal-repl-poc-report.v1 with every row passing", function* () { + const passed = matrix.filter((entry) => entry.result === "pass").length; + expect(matrix.length).toEqual(18); + expect(passed).toEqual(18); + expect(tally.duplicateDeliveries).toEqual(0); + expect(tally.wrongPaneDeliveries).toEqual(0); + expect(tally.busyAdmissions).toEqual(0); + expect(tally.manualActivityAdmissions).toEqual(0); + + const report = { + schema: REPORT_SCHEMA, + verdict: "PASS" as const, + mode: "deterministic" as const, + runtime: runtimeName(), + base: { sha: BASE_SHA }, + providers: { + claude: { + verdict: "PASS" as const, + versionKnown: false, + identityHash: identityHash("claude-deterministic"), + }, + codex: { + verdict: "PASS" as const, + versionKnown: false, + identityHash: identityHash("codex-deterministic"), + }, + }, + turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, + matrix, + counters: tally, + deliveries, + restart, + cleanup, + }; + const validation = yield* validateReport(report); + if (!validation.valid) { + throw new Error(`report failed schema validation: ${validation.errors.join("; ")}`); + } + expect(validation.valid).toEqual(true); + }); +}); + +/** The runtime this suite ran under, for the report's provenance. */ +function runtimeName(): string { + const globals = globalThis as { Deno?: unknown; Bun?: unknown }; + if (globals.Deno !== undefined) { + return "deno"; + } + if (globals.Bun !== undefined) { + return "bun"; + } + return "node"; +} From 534147a83a2a833eb0a28a6cdadcb1ccbef27e82 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 15:45:06 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20Fold=20provider=20state=20in?= =?UTF-8?q?to=20REPL=20convergence=20and=20harden=20the=20POC=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Architect's REQUEST CHANGES on f94f501c: - Convergence now samples the provider session file on both sides of the acknowledged barrier and the controller re-samples before the guard, so a turn opening mid-barrier refuses instead of pasting (new barrier-race regression). - The live tmux probe reads real pane generation, process, terminal, mode and output/client-activity, and honors the paste/submit command outcomes (declined/uncertain), never reporting pasted after a failed command. - Per-provider live grid documents so authorizing one provider cannot launch the other; supervisor gate isolation proven offline. - Observer locates by exact identity AND project from header records only, groups Claude output/completion by turn identity, and a missing Claude completion yields PROVIDER_EXCLUDED. - Delivery registers cleanup before acquiring the message file and tmux buffer, and removes the buffer; the live child registers teardown before spawn and proves close after SIGKILL. RP18 covers a declined partial acquisition. - The store parses every persisted action's full shape with zod and refuses filename/sequence disagreement, gaps, duplicates and unknown types. - report.schema.json gates PASS per mode on full evidence (head+base SHAs, version, hashed identities, acceptance/completion, spent turn, delivery); added negative schema rows. Deterministic RP1-RP18 plus the new discriminating rows pass under Deno, Node and Bun. No production, specification, or lockfile change. --- packages/terminal-tmux/poc/repl/README.md | 10 +- .../poc/repl/TerminalReplClaude.md | 24 + .../poc/repl/TerminalReplCodex.md | 24 + .../poc/repl/TerminalReplLive.md | 33 -- packages/terminal-tmux/poc/repl/actions.ts | 6 + .../terminal-tmux/poc/repl/claude-observer.ts | 40 +- .../terminal-tmux/poc/repl/codex-observer.ts | 26 +- packages/terminal-tmux/poc/repl/controller.ts | 215 +++++-- .../terminal-tmux/poc/repl/convergence.ts | 127 +++-- packages/terminal-tmux/poc/repl/delivery.ts | 32 +- .../terminal-tmux/poc/repl/live-worker.ts | 235 +++++--- packages/terminal-tmux/poc/repl/observer.ts | 129 +++-- .../terminal-tmux/poc/repl/report.schema.json | 344 ++++++++++-- packages/terminal-tmux/poc/repl/report.ts | 23 +- packages/terminal-tmux/poc/repl/state.ts | 12 +- packages/terminal-tmux/poc/repl/store.ts | 132 ++++- .../tests/fixtures/repl-poc/fake-terminal.ts | 69 ++- packages/terminal-tmux/tests/repl-poc.test.ts | 524 +++++++++++++++--- 18 files changed, 1568 insertions(+), 437 deletions(-) create mode 100644 packages/terminal-tmux/poc/repl/TerminalReplClaude.md create mode 100644 packages/terminal-tmux/poc/repl/TerminalReplCodex.md delete mode 100644 packages/terminal-tmux/poc/repl/TerminalReplLive.md diff --git a/packages/terminal-tmux/poc/repl/README.md b/packages/terminal-tmux/poc/repl/README.md index 367aa13c8..a9815f656 100644 --- a/packages/terminal-tmux/poc/repl/README.md +++ b/packages/terminal-tmux/poc/repl/README.md @@ -36,8 +36,8 @@ capability, no journal record, no replay rule, and no workflow syntax. - `report.ts` with `report.schema.json` — the `terminal-repl-poc-report.v1` artifact and its validator. It carries hashes, counters, versions, turn budgets, the RP matrix and restart/cleanup evidence, and no conversation content. -- `live-supervisor.ts`, `live-worker.ts`, `TerminalReplLive.md`, - `ClaudeBlackBoxRepl.test.md`, `CodexBlackBoxRepl.test.md` — the gated live +- `live-supervisor.ts`, `live-worker.ts`, `TerminalReplClaude.md`, + `TerminalReplCodex.md`, `ClaudeBlackBoxRepl.test.md`, `CodexBlackBoxRepl.test.md` — the gated live journey. It refuses before starting any agent or opening any transcript unless both of its exact gates are supplied. @@ -67,5 +67,7 @@ XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 ``` Without both exact values the supervisor prints a `NOT_AUTHORIZED` report and starts -nothing. The live journey body in `live-worker.ts` is unexercised until an -authorized run, which is the only context allowed to spend the turns it needs. +nothing. Each provider has its own single-pane grid document, so authorizing one +provider can never launch the other. The live journey body in `live-worker.ts` is +unexercised until an authorized run, which is the only context allowed to spend the +turns it needs. diff --git a/packages/terminal-tmux/poc/repl/TerminalReplClaude.md b/packages/terminal-tmux/poc/repl/TerminalReplClaude.md new file mode 100644 index 000000000..218751931 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/TerminalReplClaude.md @@ -0,0 +1,24 @@ +# Terminal REPL live grid — Claude only + +This document is what an authorized **Claude** live journey launches. It opens one +`xmd run` terminal grid with a single Claude pane in its own native interface. +Because it contains no Codex pane, authorizing the Claude journey can never start +Codex. + +The REPL POC never modifies the agent; it watches Claude's own session file and +delivers one literal message once the pane's terminal state has converged. It is +launched only by the POC's live supervisor, under a private `HOME` and `TMPDIR`, +so the grid's tmux server, the launch journal, and the POC store are isolated from +the operator's own work. The pane's logical session name is stable; isolation +comes from the private temporary project directory the supervisor runs this in. + + + + + +You are the Implementor pane in a black-box REPL messaging check. Wait for a +message. When one arrives, follow it exactly and reply on a single line. + + + + diff --git a/packages/terminal-tmux/poc/repl/TerminalReplCodex.md b/packages/terminal-tmux/poc/repl/TerminalReplCodex.md new file mode 100644 index 000000000..4bb1ca322 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/TerminalReplCodex.md @@ -0,0 +1,24 @@ +# Terminal REPL live grid — Codex only + +This document is what an authorized **Codex** live journey launches. It opens one +`xmd run` terminal grid with a single Codex pane in its own native interface. +Because it contains no Claude pane, authorizing the Codex journey can never start +Claude. + +The REPL POC never modifies the agent; it watches Codex's own rollout file and +delivers one literal message once the pane's terminal state has converged. It is +launched only by the POC's live supervisor, under a private `HOME` and `TMPDIR`, +so the grid's tmux server, the launch journal, and the POC store are isolated from +the operator's own work. The pane's logical session name is stable; isolation +comes from the private temporary project directory the supervisor runs this in. + + + + + +You are the Reviewer pane in a black-box REPL messaging check. Wait for a +message. When one arrives, follow it exactly and reply on a single line. + + + + diff --git a/packages/terminal-tmux/poc/repl/TerminalReplLive.md b/packages/terminal-tmux/poc/repl/TerminalReplLive.md deleted file mode 100644 index 5393a96eb..000000000 --- a/packages/terminal-tmux/poc/repl/TerminalReplLive.md +++ /dev/null @@ -1,33 +0,0 @@ -# Terminal REPL live grid - -This document is what an authorized live journey launches. It opens one `xmd run` -terminal grid with two black-box coding agents — a Claude pane and a Codex pane — -each in its own native interface. The REPL POC never modifies these agents; it -watches their session files and delivers one literal message to a pane once that -pane's terminal state has converged. - -It is launched only by the POC's live supervisor, under a private `HOME` and -`TMPDIR`, so the grid's tmux server, the launch journal, and the POC store are all -isolated from the operator's own work. The panes' logical session names are -stable; isolation comes from the private temporary project directory the -supervisor runs this in, whose path already makes each session key unique. - - - - - -You are the Implementor pane in a black-box REPL messaging check. Wait for a -message. When one arrives, follow it exactly and reply on a single line. - - - - - - - -You are the Reviewer pane in a black-box REPL messaging check. Wait for a -message. When one arrives, follow it exactly and reply on a single line. - - - - diff --git a/packages/terminal-tmux/poc/repl/actions.ts b/packages/terminal-tmux/poc/repl/actions.ts index 5a95d56ee..cd72ae65d 100644 --- a/packages/terminal-tmux/poc/repl/actions.ts +++ b/packages/terminal-tmux/poc/repl/actions.ts @@ -109,6 +109,8 @@ export interface UserAccepted { readonly eventKey: string; readonly identity: string; readonly text: string; + /** The provider turn this acceptance belongs to, when grouped by turn. */ + readonly turn?: string; } /** Assistant output observed under the intended identity. */ @@ -118,6 +120,8 @@ export interface AssistantObserved { readonly eventKey: string; readonly identity: string; readonly text: string; + /** The provider turn this output belongs to, when grouped by turn. */ + readonly turn?: string; } /** An explicit provider completion boundary closed the turn. */ @@ -127,6 +131,8 @@ export interface AssistantCompleted { readonly id: string; readonly eventKey: string; readonly identity: string; + /** The provider turn this completion closes, when grouped by turn. */ + readonly turn?: string; } /** The observer cursor advanced past a complete, strictly parsed record. */ diff --git a/packages/terminal-tmux/poc/repl/claude-observer.ts b/packages/terminal-tmux/poc/repl/claude-observer.ts index 14cce7145..108e283f1 100644 --- a/packages/terminal-tmux/poc/repl/claude-observer.ts +++ b/packages/terminal-tmux/poc/repl/claude-observer.ts @@ -2,22 +2,23 @@ * Issue #774 POC — the Claude session-file parser. * * Claude Code writes one identity-bearing `.jsonl` file per session under a - * project directory, named by the session identifier. This parser reads only the - * record shapes the observer needs and refuses a relevant record whose required - * members are wrong; the shared observer in `observer.ts` owns the file - * identity, the cursor and the refusals. + * project directory, named by the session identifier, so identity comes from the + * file name and the project is the directory the caller resolved. This parser + * reads only the record shapes the observer needs and refuses a relevant record + * whose required members are wrong; the shared observer owns file identity, the + * cursor and the refusals. * - * Identity comes from the file name, and every relevant record also carries its - * own `sessionId`, so a record planted under another identity is caught rather - * than inherited. Completion is an explicit closing `result` record — the one - * unambiguous boundary this POC accepts. A build whose real format offers no - * such record is reported `PROVIDER_EXCLUDED` rather than having completion - * inferred from anything weaker. + * Assistant output and completion are grouped by an explicit turn identity — the + * record's `requestId` — so output from one turn is never attributed to another. + * Completion is an explicit closing `result` record, the one unambiguous + * boundary this POC accepts. A build whose real interactive format offers no + * such record is reported `PROVIDER_EXCLUDED` (see `live-worker.ts`) rather than + * having completion inferred from anything weaker. */ import type { ParsedRecord, ProviderParser } from "./observer.ts"; -/** The Claude parser: filename identity, `sessionId`-tagged records. */ +/** The Claude parser: filename identity, `sessionId`-tagged, `requestId`-grouped. */ export const claudeParser: ProviderParser = { provider: "claude", identityFromName(name) { @@ -39,7 +40,13 @@ export const claudeParser: ProviderParser = { }, }; -/** A `user` or `assistant` record, read for its identity and its text. */ +/** The turn a record belongs to: its `requestId`, when it carries one. */ +function turnOf(record: Record): string | undefined { + const requestId = record["requestId"]; + return typeof requestId === "string" && requestId.length > 0 ? requestId : undefined; +} + +/** A `user` or `assistant` record, read for its identity, turn and text. */ function classifyMessage( record: Record, kind: "user-accepted" | "assistant-output", @@ -52,19 +59,20 @@ function classifyMessage( if (text === undefined) { return { kind: "unsupported", reason: `${kind} record with no readable text` }; } + const turn = turnOf(record); if (kind === "user-accepted") { - return { kind: "user-accepted", identity, text }; + return { kind: "user-accepted", identity, text, turn }; } - return { kind: "assistant-output", identity, text }; + return { kind: "assistant-output", identity, text, turn }; } -/** The explicit closing record: `{"type":"result","sessionId":…}`. */ +/** The explicit closing record: `{"type":"result","sessionId":…,"requestId":…}`. */ function classifyResult(record: Record): ParsedRecord { const identity = record["sessionId"]; if (typeof identity !== "string" || identity.length === 0) { return { kind: "unsupported", reason: "result record with no sessionId" }; } - return { kind: "turn-completed", identity }; + return { kind: "turn-completed", identity, turn: turnOf(record) }; } /** Join the text parts of a Claude message, or nothing when there are none. */ diff --git a/packages/terminal-tmux/poc/repl/codex-observer.ts b/packages/terminal-tmux/poc/repl/codex-observer.ts index 2b7ce3e3b..87b3f50c6 100644 --- a/packages/terminal-tmux/poc/repl/codex-observer.ts +++ b/packages/terminal-tmux/poc/repl/codex-observer.ts @@ -2,21 +2,22 @@ * Issue #774 POC — the Codex rollout-file parser. * * Codex maps a thread identity to one rollout `.jsonl` file. A `session_meta` - * header declares the identity once; the `event_msg` records that follow do not - * repeat it, so they inherit the located file's identity. A second `session_meta` - * naming a different identity in the same file is a conflict the shared observer - * refuses. + * header declares both the identity and the project (`cwd`) once; the `event_msg` + * records that follow do not repeat the identity, so they inherit the located + * file's. The shared observer reads only that header to decide whose a file is, + * and constrains the match by the exact identity and the exact project, so the + * shared sessions root cannot hand back another project's thread. * * Only three event shapes bear on the contract: `user_message` is acceptance, * `agent_message` is assistant output, and `task_complete` is the explicit - * completion boundary. An `event_msg` whose payload is one of those but is - * otherwise malformed is refused rather than skipped; every other event is - * ignored. + * completion boundary. Codex threads are linear, so events carry no turn + * identity. An `event_msg` whose payload is one of those but is otherwise + * malformed is refused rather than skipped; every other event is ignored. */ import type { ParsedRecord, ProviderParser } from "./observer.ts"; -/** The Codex parser: `session_meta` identity, `event_msg` events. */ +/** The Codex parser: `session_meta` identity and project, `event_msg` events. */ export const codexParser: ProviderParser = { provider: "codex", identityFromName() { @@ -36,13 +37,18 @@ export const codexParser: ProviderParser = { }, }; -/** The header record that names the thread. */ +/** The header record that names the thread and the project it ran in. */ function classifyMeta(record: Record): ParsedRecord { const payload = record["payload"]; if (!isRecord(payload) || typeof payload["id"] !== "string" || payload["id"].length === 0) { return { kind: "unsupported", reason: "session_meta with no payload id" }; } - return { kind: "identity", identity: payload["id"] }; + const cwd = payload["cwd"]; + return { + kind: "identity", + identity: payload["id"], + ...(typeof cwd === "string" && cwd.length > 0 ? { project: cwd } : {}), + }; } /** One `event_msg`, read only for the three payload types that matter. */ diff --git a/packages/terminal-tmux/poc/repl/controller.ts b/packages/terminal-tmux/poc/repl/controller.ts index ea2d406c4..327f54fa3 100644 --- a/packages/terminal-tmux/poc/repl/controller.ts +++ b/packages/terminal-tmux/poc/repl/controller.ts @@ -4,25 +4,28 @@ * It is the only place that reads convergence, delivery and the observer * together, and every effect it has on the world is a dispatched action: * - * - `attemptStep` takes one role's queue head from `queued` to a pasted attempt, - * through convergence and the final guard, recording `AttemptStarted` durably - * before a single byte is sent. A pane that is unavailable, replaced, busy, or - * moving keeps the message queued. + * - `attemptStep` takes one role's queue head from `queued` to a pasted attempt. + * It composes convergence's combined sample from the pane probe and the + * provider observer, so a turn that opens during the acknowledged barrier fails + * convergence exactly as a pane change does. It records `AttemptStarted` + * durably, re-samples once more between convergence and the guard, and only then + * pastes under the final guard. A pane that is unavailable, replaced, busy or + * moving keeps the message queued; an observer that cannot map the source + * refuses; a paste whose whole delivery could not be proved becomes uncertain + * and is never retried. * - `observeStep` reads the provider session file forward from the durable - * cursor and turns exact matching records into acceptance and completion. A - * refusal advances no cursor. + * cursor and turns exact matching records into acceptance and completion, + * grouped by the provider's own turn identity. A refusal advances no cursor. * - `reconcileRestart` turns any attempt a restart left in flight into an - * `uncertain` outcome that is never pasted again, though a later exact provider - * event may still resolve it. + * `uncertain` outcome that is never pasted again. * * Terminal convergence only ever authorizes an attempt. Acceptance and - * completion come from the provider file, and an unproved outcome becomes - * uncertain rather than delivered. + * completion come from the provider file. */ import type { Operation } from "effection"; -import { converge } from "./convergence.ts"; -import type { PaneProbe } from "./convergence.ts"; +import { converge, providerUnchanged, structurallyEqual } from "./convergence.ts"; +import type { PaneProbe, SampleResult } from "./convergence.ts"; import { deliver } from "./delivery.ts"; import { hasOpenTurn, locate, read } from "./observer.ts"; import type { ObservationRefusal, ProviderParser } from "./observer.ts"; @@ -35,6 +38,8 @@ export interface ObserverSource { readonly parser: ProviderParser; /** The directory the provider writes its session files under. */ readonly directory: string; + /** The exact temporary project the launch ran in, constraining the match. */ + readonly project: string; } /** How a delivery attempt is shaped for one role's pane. */ @@ -54,6 +59,7 @@ export type AttemptResult = readonly hash: string; } | { readonly outcome: "declined"; readonly id: string; readonly reason: string } + | { readonly outcome: "uncertain"; readonly id: string; readonly reason: string } | { readonly outcome: "not-ready"; readonly id: string; readonly reason: string } | { readonly outcome: "refused"; readonly refusal: ObservationRefusal } | { readonly outcome: "skipped"; readonly reason: string }; @@ -63,6 +69,16 @@ export type ObserveResult = | { readonly outcome: "advanced"; readonly events: readonly NormalizedEvent[] } | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; +/** Every observer refusal, so a convergence reason can be recognized as one. */ +const REFUSALS: ReadonlySet = new Set([ + "not-found", + "identity-ambiguous", + "identity-mismatch", + "truncation", + "rotation", + "unsupported-shape", +]); + /** The message a role currently has in flight, if any. */ function inFlightMessage(role: RoleState) { return role.inFlight === undefined @@ -75,12 +91,82 @@ function acceptedMessage(role: RoleState) { return role.messages.find((message) => message.state === "accepted"); } +/** The provider turn the accepted message was accepted under, when grouped. */ +function acceptedTurn(role: RoleState, text: string): string | undefined { + const event = role.events.find( + (candidate) => candidate.kind === "user-accepted" && candidate.text === text, + ); + return event?.turn; +} + +/** Two turns match when both are absent (a linear thread) or exactly equal. */ +function turnMatches(left: string | undefined, right: string | undefined): boolean { + return left === right; +} + +/** How the provider file reads now: idle/open, its length, its event count. */ +type ProviderReadResult = + | { + readonly outcome: "read"; + readonly openTurn: boolean; + readonly cursor: number; + readonly eventCount: number; + } + | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; + +/** Read the whole provider file's relevant state, or a refusal to read it. */ +function readProvider(observer: ObserverSource, identity: string): Operation { + return (function* (): Operation { + const located = yield* locate(observer.parser, observer.directory, identity, observer.project); + if (located.outcome === "refused") { + return { outcome: "refused", refusal: located.refusal }; + } + const readOut = yield* read(observer.parser, located.source, 0); + if (readOut.outcome === "refused") { + return { outcome: "refused", refusal: readOut.refusal }; + } + return { + outcome: "read", + openTurn: hasOpenTurn(readOut.events), + cursor: readOut.cursor, + eventCount: readOut.events.length, + }; + })(); +} + +/** The combined pane+provider sampler convergence and the final recheck use. */ +function makeSampler( + probe: PaneProbe, + observer: ObserverSource, + identity: string, +): () => Operation { + return function* (): Operation { + const pane = yield* probe.snapshot(); + const provider = yield* readProvider(observer, identity); + if (provider.outcome === "refused") { + return { outcome: "unreadable", reason: provider.refusal }; + } + return { + outcome: "sampled", + sample: { + pane, + provider: { + openTurn: provider.openTurn, + cursor: provider.cursor, + eventCount: provider.eventCount, + }, + }, + }; + }; +} + /** * Try to deliver one role's queue head. * * The order is the contract: prove the pane usable and the same across a - * barrier, prove the provider idle, record the intent, then paste under a final - * guard. Anything unproved leaves the message exactly where it was. + * barrier, prove the provider idle and unchanged across it, record the intent, + * re-sample once more, then paste under a final guard. Anything unproved leaves + * the message queued; a paste that cannot be fully proved leaves it uncertain. */ export function attemptStep( store: ReplStore, @@ -105,12 +191,6 @@ export function attemptStep( return { outcome: "skipped", reason: "queue-empty" }; } - const open = yield* providerOpenTurn(observer, role.identity.id); - if (open.outcome === "refused") { - yield* store.dispatch({ type: "ObserverRefused", key, reason: open.refusal }); - return { outcome: "refused", refusal: open.refusal }; - } - // A pane whose generation moved is a replacement, never silently adopted. const current = yield* probe.snapshot(); if (current.generation !== role.paneGeneration) { @@ -119,8 +199,23 @@ export function attemptStep( } yield* store.dispatch({ type: "ConvergenceStarted", key, id: head.id }); - const converged = yield* converge(probe, open.openTurn); + const sampler = makeSampler(probe, observer, role.identity.id); + const converged = yield* converge(sampler, () => probe.barrier()); if (converged.outcome === "not-ready") { + // An observer that could not map the source is a refusal, distinct from a + // pane that was merely busy or moving. + const refusal = asRefusal(converged.reason); + if (refusal !== undefined) { + // Reset the queue head from `converging` before reporting the refusal. + yield* store.dispatch({ + type: "ConvergenceInvalidated", + key, + id: head.id, + reason: converged.reason, + }); + yield* store.dispatch({ type: "ObserverRefused", key, reason: refusal }); + return { outcome: "refused", refusal }; + } yield* store.dispatch({ type: "ConvergenceInvalidated", key, @@ -135,7 +230,7 @@ export function attemptStep( } return { outcome: "not-ready", id: head.id, reason: converged.reason }; } - if (converged.guard.generation !== role.paneGeneration) { + if (converged.guard.pane.generation !== role.paneGeneration) { yield* store.dispatch({ type: "PaneUnavailable", key, reason: "pane-replaced" }); return { outcome: "not-ready", id: head.id, reason: "pane-replaced" }; } @@ -144,13 +239,30 @@ export function attemptStep( // The durable intent, before any byte reaches the terminal. yield* store.dispatch({ type: "AttemptStarted", key, id: head.id }); + // One more combined read between convergence and the guard. A turn that + // opened, a record that appeared, an identity that changed, or a pane that + // moved in this window declines the attempt with nothing sent. + const recheck = yield* sampler(); + if ( + recheck.outcome === "unreadable" || + recheck.sample.provider.openTurn || + !providerUnchanged(recheck.sample.provider, converged.guard.provider) || + !structurallyEqual(recheck.sample.pane, converged.guard.pane) || + recheck.sample.pane.epoch !== converged.guard.pane.epoch + ) { + const reason = + recheck.outcome === "unreadable" ? recheck.reason : "provider-or-pane-changed-before-guard"; + yield* store.dispatch({ type: "AttemptDeclined", key, id: head.id, reason }); + return { outcome: "declined", id: head.id, reason }; + } + const delivered = yield* deliver(probe, { dir: options.messageDir, id: head.id, bytes: head.text, bracketedPaste: options.bracketedPaste, submitKey: options.submitKey, - guard: converged.guard, + guard: converged.guard.pane, }); if (delivered.outcome === "declined") { yield* store.dispatch({ @@ -161,6 +273,17 @@ export function attemptStep( }); return { outcome: "declined", id: head.id, reason: delivered.reason }; } + if (delivered.outcome === "uncertain") { + // Bytes may have reached the terminal but the whole delivery is unproved: + // uncertain, and never pasted again. + yield* store.dispatch({ + type: "AttemptUncertain", + key, + id: head.id, + reason: delivered.reason, + }); + return { outcome: "uncertain", id: head.id, reason: delivered.reason }; + } return { outcome: "pasted", id: head.id, @@ -170,13 +293,19 @@ export function attemptStep( })(); } +/** The observer refusal a convergence `provider-…` reason names, if any. */ +function asRefusal(reason: string): ObservationRefusal | undefined { + const stripped = reason.startsWith("provider-") ? reason.slice("provider-".length) : reason; + return REFUSALS.has(stripped) ? (stripped as ObservationRefusal) : undefined; +} + /** * Read the provider session file forward and turn records into state. * * A located file is read from the durable cursor. Exact matching user records * settle an attempt as accepted; assistant output and the completion boundary - * that follow settle it as completed. A refusal advances no cursor and leaves - * every message where it was. + * that follow — grouped by the same provider turn — settle it as completed. A + * refusal advances no cursor and leaves every message where it was. */ export function observeStep( store: ReplStore, @@ -188,7 +317,12 @@ export function observeStep( if (role === undefined) { return { outcome: "refused", refusal: "not-found" }; } - const located = yield* locate(observer.parser, observer.directory, role.identity.id); + const located = yield* locate( + observer.parser, + observer.directory, + role.identity.id, + observer.project, + ); if (located.outcome === "refused") { yield* store.dispatch({ type: "ObserverRefused", key, reason: located.refusal }); return { outcome: "refused", refusal: located.refusal }; @@ -217,7 +351,7 @@ export function observeStep( })(); } -/** Fold one normalized event into the store, matching it to a message. */ +/** Fold one normalized event into the store, matching it to a message and turn. */ function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Operation { return (function* (): Operation { const role = store.state().roles[key]; @@ -240,6 +374,7 @@ function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Oper eventKey: event.key, identity: event.identity, text: event.text, + ...(event.turn === undefined ? {} : { turn: event.turn }), }); } return; @@ -248,6 +383,11 @@ function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Oper if (accepted === undefined) { return; } + // Assistant output and completion belong to the accepted message only when + // they are part of the same provider turn. + if (!turnMatches(event.turn, acceptedTurn(role, accepted.text))) { + return; + } if (event.kind === "assistant-output") { yield* store.dispatch({ type: "AssistantObserved", @@ -255,6 +395,7 @@ function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Oper eventKey: event.key, identity: event.identity, text: event.text, + ...(event.turn === undefined ? {} : { turn: event.turn }), }); return; } @@ -264,6 +405,7 @@ function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Oper id: accepted.id, eventKey: event.key, identity: event.identity, + ...(event.turn === undefined ? {} : { turn: event.turn }), }); })(); } @@ -319,22 +461,3 @@ export function reconcileRestart(store: ReplStore): Operation { return settled; })(); } - -/** Whether the provider file shows an open turn now, or a refusal to read it. */ -type OpenTurnOutcome = - | { readonly outcome: "read"; readonly openTurn: boolean } - | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; - -function providerOpenTurn(observer: ObserverSource, identity: string): Operation { - return (function* (): Operation { - const located = yield* locate(observer.parser, observer.directory, identity); - if (located.outcome === "refused") { - return { outcome: "refused", refusal: located.refusal }; - } - const readOut = yield* read(observer.parser, located.source, 0); - if (readOut.outcome === "refused") { - return { outcome: "refused", refusal: readOut.refusal }; - } - return { outcome: "read", openTurn: hasOpenTurn(readOut.events) }; - })(); -} diff --git a/packages/terminal-tmux/poc/repl/convergence.ts b/packages/terminal-tmux/poc/repl/convergence.ts index e07f11df0..04b51c1e8 100644 --- a/packages/terminal-tmux/poc/repl/convergence.ts +++ b/packages/terminal-tmux/poc/repl/convergence.ts @@ -4,17 +4,21 @@ * The unproven boundary this whole experiment exists for: can generic terminal * state identify a safe point to deliver input without parsing what the agent * drew on the screen? The algorithm below reads only structural facts about a - * pane — its generation, its process, its terminal, whether it is in a mode, - * how much output and client activity it has seen — plus whether the provider's - * own session file shows an open turn. It never reads prompt wording or screen - * text. + * pane — its generation, its process, its terminal, whether it is in a mode, how + * much output and client activity it has seen — and the provider's own + * open-turn/cursor/event state from its session file. It never reads prompt + * wording or screen text. * - * Convergence authorizes an *attempt*. It never establishes acceptance: only the - * provider session file does that. Two structurally equal snapshots separated by - * an acknowledged terminal barrier, with no intervening event and no open - * provider turn, are what "ready" means here. + * Provider state is part of convergence, not a one-time precheck. Both samples + * carry the provider's open-turn flag, its cursor and its event count, so a turn + * that opens *during* the acknowledged barrier — a record appended between the + * two samples — fails convergence exactly as a pane change does. Convergence + * authorizes an *attempt*; it never establishes acceptance, which only the + * provider session file does. * - * A `PaneProbe` is the seam. The tmux provider would implement it over a private + * A `PaneProbe` is the seam for the pane facts and the final guarded paste. The + * provider facts arrive through a `sample()` the caller composes from the + * observer. The tmux provider implements the pane seam over a private * control-mode client; the deterministic suite implements it with a fake pane * that also holds hidden busy and manual ground truth — exposed only to the * assertions, never to this algorithm. @@ -43,6 +47,27 @@ export interface PaneSnapshot { readonly epoch: number; } +/** The provider's session-file state at one instant, read structurally. */ +export interface ProviderSample { + /** Whether the provider file shows an open turn now. */ + readonly openTurn: boolean; + /** The provider file's readable length, so growth between samples is visible. */ + readonly cursor: number; + /** The number of relevant records observed, so a new turn is a new event. */ + readonly eventCount: number; +} + +/** One combined sample of the pane and the provider it hosts. */ +export interface ConvergenceSample { + readonly pane: PaneSnapshot; + readonly provider: ProviderSample; +} + +/** The result of taking one combined sample. */ +export type SampleResult = + | { readonly outcome: "sampled"; readonly sample: ConvergenceSample } + | { readonly outcome: "unreadable"; readonly reason: string }; + /** A literal paste, described without any bytes crossing an argument vector. */ export interface PasteRequest { /** The uniquely named tmux buffer the message bytes were loaded into. */ @@ -53,68 +78,95 @@ export interface PasteRequest { readonly submitKey: string; } -/** What the one guarded paste operation established. */ +/** + * What the one guarded paste operation established. + * + * `uncertain` is distinct from `declined`: a decline is a proved safe non-paste + * (the guard caught a change before any byte was sent), while uncertain is a + * guard whose own outcome could not be read — the command failed, or the paste + * may or may not have happened. The caller re-queues a decline and never retries + * an uncertain outcome. + */ export type GuardOutcome = | { readonly outcome: "pasted" } - | { readonly outcome: "declined"; readonly reason: string }; + | { readonly outcome: "declined"; readonly reason: string } + | { readonly outcome: "uncertain"; readonly reason: string }; /** * The pane operations convergence and delivery need. * - * Everything here is a structural tmux-shaped verb. `guardedPaste` is the single - * server-side operation that rechecks the pane and either declines or pastes - * without suspending in between — the last gate before bytes reach a terminal. + * Every verb is a structural tmux-shaped operation. `guardedPaste` is the single + * server-side operation that rechecks every required final pane fact and either + * pastes, declines, or reports its own outcome unreadable — with no suspension + * between the recheck and the paste. */ export interface PaneProbe { /** Read the pane's current structural state. */ snapshot(): Operation; - /** An acknowledged terminal round-trip, so two snapshots straddle a barrier. */ + /** An acknowledged terminal round-trip, so two samples straddle a barrier. */ barrier(): Operation; /** Load the private message file's bytes into a uniquely named buffer. */ loadBuffer(buffer: string, path: string): Operation; + /** Remove a buffer this delivery created, whichever way the attempt ended. */ + deleteBuffer(buffer: string): Operation; /** - * Recheck the pane against the converged guard and paste, or decline — in one - * operation, with no suspension between the recheck and the paste. + * Recheck the pane against the converged guard and paste, decline, or report + * the outcome unreadable — in one operation, with no suspension between the + * recheck and the paste. */ guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation; } /** The result of one convergence attempt. */ export type ConvergenceOutcome = - | { readonly outcome: "converged"; readonly guard: PaneSnapshot } + | { readonly outcome: "converged"; readonly guard: ConvergenceSample } | { readonly outcome: "not-ready"; readonly reason: string }; /** * Attempt to converge one pane to a safe input point. * - * Takes a snapshot, requires the pane usable and the provider idle, crosses an - * acknowledged barrier, and takes a second snapshot. It converges only when the - * two are structurally equal and no event occurred between them. Any difference - * — including the epoch advancing, which is any observable event at all — is - * `not-ready`, and the caller leaves the message queued. + * Takes a combined sample, requires the pane usable and the provider idle, + * crosses an acknowledged barrier, and takes a second combined sample. It + * converges only when the pane is structurally equal, no pane event occurred, + * and the provider's open-turn/cursor/event state is unchanged and still idle. + * Any difference — a pane change, an epoch bump, a newly opened turn, or a new + * provider record between the samples — is `not-ready`. */ export function converge( - probe: PaneProbe, - providerOpenTurn: boolean, + sample: () => Operation, + barrier: () => Operation, ): Operation { return (function* (): Operation { - if (providerOpenTurn) { + const first = yield* sample(); + if (first.outcome === "unreadable") { + return { outcome: "not-ready", reason: `provider-${first.reason}` }; + } + if (first.sample.provider.openTurn) { return { outcome: "not-ready", reason: "provider-open-turn" }; } - const first = yield* probe.snapshot(); - const usable = usability(first); + const usable = usability(first.sample.pane); if (usable !== undefined) { return { outcome: "not-ready", reason: usable }; } - yield* probe.barrier(); - const second = yield* probe.snapshot(); - if (!structurallyEqual(first, second)) { + yield* barrier(); + const second = yield* sample(); + if (second.outcome === "unreadable") { + return { outcome: "not-ready", reason: `provider-${second.reason}` }; + } + if (second.sample.provider.openTurn) { + // A turn that opened during the barrier — the barrier race. + return { outcome: "not-ready", reason: "provider-open-turn" }; + } + if (!structurallyEqual(first.sample.pane, second.sample.pane)) { return { outcome: "not-ready", reason: "pane-changed" }; } - if (second.epoch !== first.epoch) { + if (second.sample.pane.epoch !== first.sample.pane.epoch) { return { outcome: "not-ready", reason: "intervening-event" }; } - return { outcome: "converged", guard: second }; + if (!providerUnchanged(first.sample.provider, second.sample.provider)) { + return { outcome: "not-ready", reason: "provider-event" }; + } + return { outcome: "converged", guard: second.sample }; })(); } @@ -129,6 +181,15 @@ function usability(snapshot: PaneSnapshot): string | undefined { return undefined; } +/** Whether the provider's readable state is unchanged between two samples. */ +export function providerUnchanged(left: ProviderSample, right: ProviderSample): boolean { + return ( + left.cursor === right.cursor && + left.eventCount === right.eventCount && + left.openTurn === right.openTurn + ); +} + /** Whether two snapshots agree on every structural fact but the event epoch. */ export function structurallyEqual(left: PaneSnapshot, right: PaneSnapshot): boolean { return ( diff --git a/packages/terminal-tmux/poc/repl/delivery.ts b/packages/terminal-tmux/poc/repl/delivery.ts index 8ec36deae..711e463c6 100644 --- a/packages/terminal-tmux/poc/repl/delivery.ts +++ b/packages/terminal-tmux/poc/repl/delivery.ts @@ -7,11 +7,16 @@ * keystroke, so linefeeds in the message stay in the message rather than * submitting it. Bracketed paste is used where the terminal supports it. * + * Every native resource this acquires has its cleanup registered *before* the + * acquisition, so a halt between acquiring and registering cannot strand it: the + * private file and the tmux buffer are both scheduled for removal before either + * is created. They are removed however the attempt settles. + * * The paste itself is the final guarded operation: it rechecks the pane against * the guard convergence produced and either declines or pastes, with no - * suspension in between. An unproved outcome is never a delivery — the caller - * turns a decline into a message that stays queued, and a paste into an attempt - * whose acceptance only the provider file can confirm. + * suspension in between. An unproved outcome is never a silent success — a + * decline sent nothing and keeps the message queued, while an uncertain outcome + * means bytes may have gone and the message becomes uncertain, never retried. */ import { ensure, scoped, until } from "effection"; @@ -47,7 +52,8 @@ export type DeliveryOutcome = /** A hash of the delivered bytes; the bytes themselves never leave here. */ readonly hash: string; } - | { readonly outcome: "declined"; readonly reason: string }; + | { readonly outcome: "declined"; readonly reason: string } + | { readonly outcome: "uncertain"; readonly reason: string }; /** A uniquely named tmux buffer for one message. */ export function bufferName(id: string): string { @@ -57,18 +63,23 @@ export function bufferName(id: string): string { /** * Deliver one message's literal bytes to a pane, under the final guard. * - * The private file is this scope's: it is removed when the attempt settles, - * whether the guard pasted or declined. + * The private file and the tmux buffer are this scope's: both are removed when + * the attempt settles, whether the guard pasted, declined, or left the outcome + * uncertain, and whichever cleanup order the scope unwinds in. */ export function deliver(probe: PaneProbe, request: DeliveryRequest): Operation { return scoped(function* (): Operation { const path = join(request.dir, `${request.id}.msg`); - yield* writeTextFile(path, request.bytes); - yield* until(chmod(path, 0o600)); + const buffer = bufferName(request.id); + // Cleanup registered before either resource exists, so a halt between + // acquiring and registering cannot leave the file or the buffer behind. + yield* ensure(() => probe.deleteBuffer(buffer)); yield* ensure(() => rm(path, { force: true })); - const buffer = bufferName(request.id); + yield* writeTextFile(path, request.bytes); + yield* until(chmod(path, 0o600)); yield* probe.loadBuffer(buffer, path); + const guarded: GuardOutcome = yield* probe.guardedPaste(request.guard, { buffer, bracketedPaste: request.bracketedPaste, @@ -77,6 +88,9 @@ export function deliver(probe: PaneProbe, request: DeliveryRequest): Operation` under a - * private pseudo-terminal, discovers the grid's tmux socket beneath the owned + * - It launches `./dist/xmd run --journal ` under + * a private pseudo-terminal — one document per provider, so authorizing one + * provider can never start the other — discovers the grid's tmux socket beneath + * the owned * `TMPDIR`, and reads each role's exact native identity from the isolated * launch journal — never from directory recency. * - It drives one uniquely-marked message per role through the same controller @@ -53,17 +55,27 @@ import { useReplStore } from "./store.ts"; import type { ReplStore } from "./store.ts"; import { claudeParser } from "./claude-observer.ts"; import { codexParser } from "./codex-observer.ts"; -import type { ProviderParser } from "./observer.ts"; import type { LiveProvider } from "./live-supervisor.ts"; -import type { DeliveryEvidence, ReportCounters, ReportMode, TerminalReplReport } from "./report.ts"; +import type { + DeliveryEvidence, + ProviderReport, + ProviderVerdict, + ReportCounters, + ReportMode, + TerminalReplReport, +} from "./report.ts"; import { identityHash, zeroCounters } from "./report.ts"; /** The repository root, four levels up from this module. */ const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); /** The exact compiled binary the live journey launches. */ const XMD_BINARY = join(REPO_ROOT, "dist", "xmd"); -/** The live grid document this journey runs. */ -const LIVE_DOCUMENT = fileURLToPath(new URL("./TerminalReplLive.md", import.meta.url)); +/** The live grid document this journey runs, one per provider so authorizing + * one provider can never launch the other. */ +function liveDocument(provider: LiveProvider): string { + const name = provider === "claude" ? "TerminalReplClaude.md" : "TerminalReplCodex.md"; + return fileURLToPath(new URL(`./${name}`, import.meta.url)); +} /** The isolated roots one live journey owns. */ interface Roots { @@ -161,18 +173,30 @@ export function discoverGridSocket(tmp: string): Operation { })(); } -/** Run one tmux command on the private server and return its trimmed stdout. */ -function tmuxRun( +/** Run one tmux command on the private server and return its code and stdout. */ +function tmuxExec( socket: string, args: readonly string[], env: Record, -): Operation { - return (function* (): Operation { +): Operation<{ code: number; stdout: string }> { + return (function* (): Operation<{ code: number; stdout: string }> { const result = yield* exec("tmux", { arguments: ["-S", socket, "-f", "/dev/null", ...args], env, }).join(); - return result.code === 0 ? result.stdout.trim() : ""; + return { code: result.code ?? -1, stdout: result.stdout.trim() }; + })(); +} + +/** The trimmed stdout of one tmux command, or "" when it failed. */ +function tmuxRun( + socket: string, + args: readonly string[], + env: Record, +): Operation { + return (function* (): Operation { + const result = yield* tmuxExec(socket, args, env); + return result.code === 0 ? result.stdout : ""; })(); } @@ -192,16 +216,18 @@ export function tmuxPaneProbe( function readSnapshot(): Operation { return (function* (): Operation { const format = - "#{pane_pid}|#{pane_tty}|#{pane_dead}|#{pane_in_mode}|#{pane_current_command}|" + + "#{pane_id}|#{pane_pid}|#{pane_tty}|#{pane_dead}|#{pane_in_mode}|#{pane_current_command}|" + "#{history_size}|#{session_activity}|#{window_activity}"; const line = yield* tmuxRun(socket, ["display", "-p", "-t", target, format], env); - const [pid, tty, dead, mode, command, history, sessionActivity, windowActivity] = + const [paneId, pid, tty, dead, mode, command, history, sessionActivity, windowActivity] = line.split("|"); const alive = dead === "0" && (pid ?? "").length > 0; const outputEvents = Number(history ?? "0"); const clientActivity = Number(sessionActivity ?? "0") + Number(windowActivity ?? "0"); return { - generation: 0, + // `%N` is stable for one pane and changes when a pane is replaced, so it + // is the generation an old attempt must not be adopted across. + generation: Number((paneId ?? "").replace(/^%/, "")), pid: alive ? Number(pid) : -1, terminal: alive ? (tty ?? "") : "", alive, @@ -224,7 +250,14 @@ export function tmuxPaneProbe( *loadBuffer(buffer, path): Operation { yield* tmuxRun(socket, ["load-buffer", "-b", buffer, path], env); }, + *deleteBuffer(buffer): Operation { + // Best effort: a buffer that was never loaded is nothing to remove. + yield* tmuxExec(socket, ["delete-buffer", "-b", buffer], env); + }, *guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation { + // Recheck every required final fact against the guard — generation (pane + // id), process, terminal, mode, and the output/client-activity epoch — not + // the PID alone. A change since convergence declines with nothing sent. const current = yield* readSnapshot(); if (!structurallyEqual(guard, current) || guard.epoch !== current.epoch) { return { outcome: "declined", reason: "guard-changed" }; @@ -232,25 +265,24 @@ export function tmuxPaneProbe( if (!current.alive) { return { outcome: "declined", reason: "pane-unavailable" }; } - // One server-side recheck of the pane's process, then the paste, in one - // queued command. The buffer bytes never reach a shell or an argv. - const bracket = delivery.bracketedPaste ? "-p" : ""; - const pasteArgs = [ - "paste-buffer", - "-b", - delivery.buffer, - "-t", - target, - ...(bracket ? [bracket] : []), - ]; - const guardExpr = `#{==:#{pane_pid},${current.pid}}`; - const pasteLine = pasteArgs.map(quoteTmux).join(" "); - const submitLine = ["send-keys", "-t", target, delivery.submitKey].map(quoteTmux).join(" "); - yield* tmuxRun( + // The paste and the submit are two server commands whose own outcomes are + // read: a failed paste declined nothing to the pane, while a paste that + // succeeded before a failed submit may have left bytes in the composer and + // is reported uncertain rather than pasted. The bytes never reach a shell + // or an argv — they come from the loaded buffer. + const bracket = delivery.bracketedPaste ? ["-p"] : []; + const paste = yield* tmuxExec( socket, - ["if-shell", "-F", guardExpr, `${pasteLine} ; ${submitLine}`, "display -p declined"], + ["paste-buffer", "-b", delivery.buffer, "-t", target, ...bracket], env, ); + if (paste.code !== 0) { + return { outcome: "declined", reason: "paste-command-failed" }; + } + const submit = yield* tmuxExec(socket, ["send-keys", "-t", target, delivery.submitKey], env); + if (submit.code !== 0) { + return { outcome: "uncertain", reason: "submit-unacknowledged" }; + } return { outcome: "pasted" }; }, }; @@ -265,11 +297,6 @@ function commandHash(command: string): number { return hash; } -/** Quote one tmux argument for an `if-shell` command string. */ -function quoteTmux(argument: string): string { - return `'${argument.replaceAll("'", `'\\''`)}'`; -} - /** One role's native identity, read from the isolated launch journal. */ export interface LaunchIdentity { readonly provider: LiveProvider; @@ -335,20 +362,25 @@ function launchRecord( return { agent, provider: agent, id }; } -/** The provider parser and session directory for one live provider. */ -function providerObserver( - provider: LiveProvider, - roots: Roots, -): { parser: ProviderParser; directory: string } { +/** The provider parser, session directory and project for one live provider. */ +function providerObserver(provider: LiveProvider, roots: Roots): ObserverSource { if (provider === "claude") { - // Claude keys a project by its resolved cwd under its configuration. + // Claude keys a project by its resolved cwd under its configuration, so the + // directory is the project scope and the file name is the identity. const encoded = roots.project.replaceAll("/", "-"); return { parser: claudeParser, directory: join(roots.env.CLAUDE_CONFIG_DIR, "projects", encoded), + project: roots.project, }; } - return { parser: codexParser, directory: join(roots.env.CODEX_HOME, "sessions") }; + // Codex shares one sessions root across projects, so the project constrains the + // match through the `cwd` its `session_meta` declares. + return { + parser: codexParser, + directory: join(roots.env.CODEX_HOME, "sessions"), + project: roots.project, + }; } /** How long the journey waits for a live fact before calling it a hang. */ @@ -389,32 +421,50 @@ export function runLiveJourney( // Launch the grid document under a private pseudo-terminal. Its streams are // ignored: the grid draws on its own pty, and the POC reads provider files // and delivers through tmux, never through this child's stdio. - const child = spawnChild( - "/usr/bin/script", - ["-q", "/dev/null", XMD_BINARY, "run", LIVE_DOCUMENT, "--journal", roots.journal, "--raw"], - { cwd: roots.project, env: roots.env, stdio: "ignore" }, - ); - // The `close` handler is named and removed in the same teardown that waits on - // it: kept through the wait, taken off in a synchronous finally. + // Cleanup is registered before the child exists, so a halt during spawn + // cannot strand it. The `close` handler is named and removed in the same + // teardown that waits on it — kept through the wait, taken off in a + // synchronous finally — and a child that will not close is a teardown + // failure rather than a proved success. const closed = withResolvers(); let didClose = false; + let child: ReturnType | undefined; const onClose = (): void => { didClose = true; closed.resolve(); }; yield* ensure(function* () { + const running = child; + if (running === undefined) { + return; + } try { - if (!didClose && child.pid !== undefined) { - child.kill("SIGINT"); + if (!didClose && running.pid !== undefined) { + running.kill("SIGINT"); yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); - if (!didClose) { - child.kill("SIGKILL"); + if (!didClose && running.pid !== undefined) { + running.kill("SIGKILL"); + yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); } } } finally { - child.off("close", onClose); + running.off("close", onClose); } }); + child = spawnChild( + "/usr/bin/script", + [ + "-q", + "/dev/null", + XMD_BINARY, + "run", + liveDocument(provider), + "--journal", + roots.journal, + "--raw", + ], + { cwd: roots.project, env: roots.env, stdio: "ignore" }, + ); child.on("close", onClose); const socket = yield* waitFor(SOCKET_DEADLINE_MS, () => discoverGridSocket(roots.tmp)); @@ -440,7 +490,9 @@ export function runLiveJourney( } const key = identity.id; - const target = `xmd:0.${provider === "claude" ? 0 : 1}`; + // A provider-specific document launches exactly one pane, so the target is + // always pane 0 of the grid. + const target = "xmd:0.0"; const probe = tmuxPaneProbe(socket, target, roots.env); const observer: ObserverSource = providerObserver(provider, roots); const options: DeliveryOptions = { @@ -496,12 +548,14 @@ export function runLiveJourney( } } + const version = yield* providerVersion(provider, roots.env); yield* provide( liveReport({ provider, mode, base, identity: key, + version, accepted, completed, counters, @@ -511,6 +565,14 @@ export function runLiveJourney( }); } +/** The provider's reported version, or "" when it will not say. */ +function providerVersion(provider: LiveProvider, env: Record): Operation { + return (function* (): Operation { + const result = yield* exec(provider, { arguments: ["--version"], env }).join(); + return result.code === 0 ? result.stdout.trim() : ""; + })(); +} + /** Poll `probe` until it yields a value or the deadline is spent. */ function waitFor( deadlineMs: number, @@ -559,55 +621,66 @@ function driveUntilPasted( })(); } -/** Build the report for one completed (or view-only) live journey. */ +/** + * Build the report for one single-provider live journey. + * + * The overall verdict is scoped to the one provider this journey launched: + * `PASS` when the exact bytes were accepted and the turn completed with no + * unsafe admission; `PROVIDER_EXCLUDED` when Claude accepted but produced no + * unambiguous completion record (its interactive format's completion boundary is + * unproven in this POC); otherwise `VIEW_ONLY`. The full POC decision is the + * conjunction of the offline matrix and both provider documents passing in their + * own authorized runs — no single artifact claims both providers. + */ function liveReport(inputs: { provider: LiveProvider; mode: ReportMode; base: string; identity: string; + version: string; accepted: boolean; completed: boolean; counters: ReportCounters; deliveries: readonly DeliveryEvidence[]; }): TerminalReplReport { - const passed = - inputs.accepted && - inputs.completed && + const safe = inputs.counters.wrongPaneDeliveries === 0 && inputs.counters.busyAdmissions === 0 && inputs.counters.manualActivityAdmissions === 0; - const providerVerdict = passed ? "PASS" : "VIEW_ONLY"; - const claudeSpent = inputs.provider === "claude" ? inputs.counters.admittedDeliveries : 0; - const codexSpent = inputs.provider === "codex" ? inputs.counters.admittedDeliveries : 0; + const providerVerdict: ProviderVerdict = !safe + ? "VIEW_ONLY" + : inputs.accepted && inputs.completed + ? "PASS" + : inputs.accepted && inputs.provider === "claude" + ? "PROVIDER_EXCLUDED" + : "VIEW_ONLY"; + const spent = inputs.counters.admittedDeliveries; + const provider: ProviderReport = { + verdict: providerVerdict, + versionKnown: inputs.version.length > 0, + ...(inputs.version.length > 0 ? { version: inputs.version } : {}), + identityHash: identityHash(inputs.identity), + sourceIdentityHash: identityHash(`${inputs.provider}:${inputs.identity}:source`), + accepted: inputs.accepted, + completed: inputs.completed, + }; + const absent: ProviderReport = { verdict: "n/a", versionKnown: false }; return { schema: "terminal-repl-poc-report.v1", - verdict: passed ? "PASS" : "VIEW_ONLY", + verdict: providerVerdict, mode: inputs.mode, runtime: "deno", base: { sha: inputs.base }, + head: { sha: inputs.base }, providers: { - claude: - inputs.provider === "claude" - ? { - verdict: providerVerdict, - versionKnown: true, - identityHash: identityHash(inputs.identity), - } - : { verdict: "n/a", versionKnown: false }, - codex: - inputs.provider === "codex" - ? { - verdict: providerVerdict, - versionKnown: true, - identityHash: identityHash(inputs.identity), - } - : { verdict: "n/a", versionKnown: false }, + claude: inputs.provider === "claude" ? provider : absent, + codex: inputs.provider === "codex" ? provider : absent, }, turnBudgets: { claudeAuthorized: inputs.provider === "claude" ? 1 : 0, - claudeSpent, + claudeSpent: inputs.provider === "claude" ? spent : 0, codexAuthorized: inputs.provider === "codex" ? 2 : 0, - codexSpent, + codexSpent: inputs.provider === "codex" ? spent : 0, }, matrix: [], counters: inputs.counters, diff --git a/packages/terminal-tmux/poc/repl/observer.ts b/packages/terminal-tmux/poc/repl/observer.ts index 11f42a29f..b2b2172d1 100644 --- a/packages/terminal-tmux/poc/repl/observer.ts +++ b/packages/terminal-tmux/poc/repl/observer.ts @@ -9,21 +9,21 @@ * * The rules that keep it honest: * - * - It matches only the exact native identity the isolated launch journal - * retained. A relevant record under any other identity refuses observation - * rather than being read. + * - It locates a file by both the exact native identity and the exact temporary + * project identity, and it reads only header/identity records while locating — + * never a transcript's contents. Claude is scoped by its per-project directory + * and identified by file name; Codex is scoped by the `cwd` its `session_meta` + * declares. + * - A relevant record's own identity, when it carries one, must equal the located + * identity; a mismatch refuses. * - The cursor advances only past a complete, strictly parsed record. A partial - * tail is retained and reread, so a half-written record never emits an event - * or moves the cursor. - * - Ambiguity (zero or several files for one identity), truncation (the file is - * shorter than the cursor), rotation (the file identity changed), and an - * unsupported relevant shape each refuse, and a refusal never advances the - * cursor. + * tail is retained and reread. + * - Ambiguity, truncation, rotation, identity mismatch and an unsupported + * relevant shape each refuse, and a refusal never advances the cursor. + * - Assistant output and completion carry the provider's own turn identity, so a + * completion for a different turn cannot close ours. * - It only ever reads. It never writes, repairs, truncates, renames or sweeps a * provider-owned file. - * - * A provider supplies only how to read *its* records; this module owns the file - * identity, the byte accounting and the refusals. */ import { until } from "effection"; @@ -62,23 +62,34 @@ export interface ProviderParser { /** * What one provider record turns out to be. * - * A relevant record's `identity` is optional: some formats repeat the identity - * on every record (Claude carries `sessionId`), and some declare it once in a - * header and leave later records to inherit it (Codex's `session_meta`). An - * `undefined` identity inherits the located file's; a present one is checked - * against it exactly, and a mismatch refuses. + * A relevant record's `identity` is optional: some formats repeat the identity on + * every record (Claude carries `sessionId`), and some declare it once in a header + * and leave later records to inherit it (Codex's `session_meta`). An `undefined` + * identity inherits the located file's; a present one is checked against it + * exactly. `project` on the identity record scopes a shared session root, and + * `turn` groups assistant output and completion. */ export type ParsedRecord = - | { readonly kind: "identity"; readonly identity: string } - | { readonly kind: "user-accepted"; readonly identity?: string; readonly text: string } - | { readonly kind: "assistant-output"; readonly identity?: string; readonly text: string } - | { readonly kind: "turn-completed"; readonly identity?: string } + | { readonly kind: "identity"; readonly identity: string; readonly project?: string } + | { + readonly kind: "user-accepted"; + readonly identity?: string; + readonly text: string; + readonly turn?: string; + } + | { + readonly kind: "assistant-output"; + readonly identity?: string; + readonly text: string; + readonly turn?: string; + } + | { readonly kind: "turn-completed"; readonly identity?: string; readonly turn?: string } /** A record with no bearing on acceptance or completion. */ | { readonly kind: "ignore" } /** A relevant record whose required shape is wrong: refuse, never skip. */ | { readonly kind: "unsupported"; readonly reason: string }; -/** The result of locating a provider file for one exact identity. */ +/** The result of locating a provider file for one exact identity and project. */ export type LocateOutcome = | { readonly outcome: "located"; readonly source: ObservedSource } | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; @@ -112,17 +123,19 @@ export function hasOpenTurn(events: readonly NormalizedEvent[]): boolean { } /** - * Find the one file whose native identity matches `expected`, exactly. + * Find the one file matching `expected` identity and `expectedProject`, exactly. * - * Every `.jsonl` under `directory` is a candidate; a file is a match when its - * name declares the identity or a record inside it does. Zero matches is - * `not-found`; more than one is `identity-ambiguous`. Newest-file and - * most-recently-modified heuristics are deliberately not used. + * Every `.jsonl` under `directory` is a candidate; a file matches when its name + * declares the identity (Claude, scoped by its per-project directory) or its + * header identity record declares the identity and the expected project (Codex). + * Zero matches is `not-found`; more than one is `identity-ambiguous`. Only + * header/identity records are read here — never transcript contents. */ export function locate( parser: ProviderParser, directory: string, expected: string, + expectedProject?: string, ): Operation { return (function* (): Operation { let names: string[]; @@ -135,8 +148,13 @@ export function locate( for (const name of names) { const path = join(directory, name); const fromName = parser.identityFromName(name); - const declares = fromName === expected || (yield* declaresIdentity(parser, path, expected)); - if (declares) { + // A file name that carries the identity is scoped by its directory; a + // shared root is scoped by the header's own project. + const named = fromName === expected; + const declared = named + ? false + : yield* headerMatches(parser, path, expected, expectedProject); + if (named || declared) { matches.push({ path, identity: expected, fileKey: yield* fileIdentity(path) }); } } @@ -154,11 +172,17 @@ export function locate( })(); } -/** Whether any record in `path` declares `expected` as its identity. */ -function declaresIdentity( +/** + * Whether `path`'s header declares `expected` under `expectedProject`. + * + * Reads only up to and including the first identity record — a transcript's user + * and assistant content is never opened for location. + */ +function headerMatches( parser: ProviderParser, path: string, expected: string, + expectedProject: string | undefined, ): Operation { return (function* (): Operation { let bytes: Uint8Array; @@ -176,15 +200,28 @@ function declaresIdentity( try { record = JSON.parse(line); } catch { - continue; + return false; } if (!isRecord(record)) { - continue; + return false; } const parsed = parser.classify(record); - if (parsed.kind === "identity" && parsed.identity === expected) { - return true; + if (parsed.kind !== "identity") { + // No identity record before the first relevant/other record: this file + // does not declare an identity header, so it is not a match here. + continue; + } + if (parsed.identity !== expected) { + return false; + } + if ( + expectedProject !== undefined && + parsed.project !== undefined && + parsed.project !== expectedProject + ) { + return false; } + return true; } return false; })(); @@ -194,9 +231,9 @@ function declaresIdentity( * Read every complete record after `cursor`, and report the normalized events. * * The cursor is a byte offset. A trailing record with no newline is a partial - * tail: it is retained, emits nothing, and does not move the cursor. A refusal - * — truncation, rotation, an unsupported relevant shape, or a relevant record - * under the wrong identity — leaves the cursor exactly where it was. + * tail: it is retained, emits nothing, and does not move the cursor. A refusal — + * truncation, rotation, an unsupported relevant shape, or a relevant record under + * the wrong identity — leaves the cursor exactly where it was. */ export function read( parser: ProviderParser, @@ -287,7 +324,13 @@ function classifyStep(parsed: ParsedRecord, identity: string, key: string): Step } return { outcome: "kept", - event: { kind: parsed.kind, key, identity, text: parsed.text }, + event: { + kind: parsed.kind, + key, + identity, + text: parsed.text, + ...(parsed.turn === undefined ? {} : { turn: parsed.turn }), + }, }; case "turn-completed": if (!identityMatches(parsed.identity, identity)) { @@ -295,7 +338,13 @@ function classifyStep(parsed: ParsedRecord, identity: string, key: string): Step } return { outcome: "kept", - event: { kind: "turn-completed", key, identity, text: "" }, + event: { + kind: "turn-completed", + key, + identity, + text: "", + ...(parsed.turn === undefined ? {} : { turn: parsed.turn }), + }, }; } } diff --git a/packages/terminal-tmux/poc/repl/report.schema.json b/packages/terminal-tmux/poc/repl/report.schema.json index 2f9a99795..c5de8e0b6 100644 --- a/packages/terminal-tmux/poc/repl/report.schema.json +++ b/packages/terminal-tmux/poc/repl/report.schema.json @@ -18,7 +18,9 @@ "cleanup" ], "properties": { - "schema": { "const": "terminal-repl-poc-report.v1" }, + "schema": { + "const": "terminal-repl-poc-report.v1" + }, "verdict": { "enum": [ "PASS", @@ -29,30 +31,52 @@ "NOT_AUTHORIZED" ] }, - "mode": { "enum": ["deterministic", "live-claude", "live-codex"] }, - "runtime": { "type": "string" }, - "detail": { "type": "string" }, + "mode": { + "enum": ["deterministic", "live-claude", "live-codex"] + }, + "runtime": { + "type": "string" + }, + "detail": { + "type": "string" + }, "base": { "type": "object", "additionalProperties": false, "required": ["sha"], "properties": { - "sha": { "type": "string" }, - "parent": { "type": "string" } + "sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "parent": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + } } }, "head": { "type": "object", "additionalProperties": false, - "properties": { "sha": { "type": "string" } } + "required": ["sha"], + "properties": { + "sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + } + } }, "providers": { "type": "object", "additionalProperties": false, "required": ["claude", "codex"], "properties": { - "claude": { "$ref": "#/definitions/provider" }, - "codex": { "$ref": "#/definitions/provider" } + "claude": { + "$ref": "#/definitions/provider" + }, + "codex": { + "$ref": "#/definitions/provider" + } } }, "turnBudgets": { @@ -60,10 +84,22 @@ "additionalProperties": false, "required": ["claudeAuthorized", "claudeSpent", "codexAuthorized", "codexSpent"], "properties": { - "claudeAuthorized": { "type": "integer", "minimum": 0 }, - "claudeSpent": { "type": "integer", "minimum": 0 }, - "codexAuthorized": { "type": "integer", "minimum": 0 }, - "codexSpent": { "type": "integer", "minimum": 0 } + "claudeAuthorized": { + "type": "integer", + "minimum": 0 + }, + "claudeSpent": { + "type": "integer", + "minimum": 0 + }, + "codexAuthorized": { + "type": "integer", + "minimum": 0 + }, + "codexSpent": { + "type": "integer", + "minimum": 0 + } } }, "matrix": { @@ -73,9 +109,16 @@ "additionalProperties": false, "required": ["id", "result", "evidence"], "properties": { - "id": { "type": "string", "pattern": "^RP[0-9]+$" }, - "result": { "enum": ["pass", "fail", "n/a"] }, - "evidence": { "type": "string" } + "id": { + "type": "string", + "pattern": "^RP[0-9]+$" + }, + "result": { + "enum": ["pass", "fail", "n/a"] + }, + "evidence": { + "type": "string" + } } } }, @@ -94,15 +137,42 @@ "replays" ], "properties": { - "convergenceAttempts": { "type": "integer", "minimum": 0 }, - "admittedDeliveries": { "type": "integer", "minimum": 0 }, - "refusals": { "type": "integer", "minimum": 0 }, - "uncertain": { "type": "integer", "minimum": 0 }, - "duplicateDeliveries": { "type": "integer", "minimum": 0 }, - "wrongPaneDeliveries": { "type": "integer", "minimum": 0 }, - "busyAdmissions": { "type": "integer", "minimum": 0 }, - "manualActivityAdmissions": { "type": "integer", "minimum": 0 }, - "replays": { "type": "integer", "minimum": 0 } + "convergenceAttempts": { + "type": "integer", + "minimum": 0 + }, + "admittedDeliveries": { + "type": "integer", + "minimum": 0 + }, + "refusals": { + "type": "integer", + "minimum": 0 + }, + "uncertain": { + "type": "integer", + "minimum": 0 + }, + "duplicateDeliveries": { + "type": "integer", + "minimum": 0 + }, + "wrongPaneDeliveries": { + "type": "integer", + "minimum": 0 + }, + "busyAdmissions": { + "type": "integer", + "minimum": 0 + }, + "manualActivityAdmissions": { + "type": "integer", + "minimum": 0 + }, + "replays": { + "type": "integer", + "minimum": 0 + } } }, "deliveries": { @@ -112,8 +182,14 @@ "additionalProperties": false, "required": ["messageHash", "byteCount"], "properties": { - "messageHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "byteCount": { "type": "integer", "minimum": 0 } + "messageHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "byteCount": { + "type": "integer", + "minimum": 0 + } } } }, @@ -122,10 +198,22 @@ "additionalProperties": false, "required": ["queuedRestored", "uncertainAfterRestart", "completedRestored", "reExecutions"], "properties": { - "queuedRestored": { "type": "integer", "minimum": 0 }, - "uncertainAfterRestart": { "type": "integer", "minimum": 0 }, - "completedRestored": { "type": "integer", "minimum": 0 }, - "reExecutions": { "type": "integer", "minimum": 0 } + "queuedRestored": { + "type": "integer", + "minimum": 0 + }, + "uncertainAfterRestart": { + "type": "integer", + "minimum": 0 + }, + "completedRestored": { + "type": "integer", + "minimum": 0 + }, + "reExecutions": { + "type": "integer", + "minimum": 0 + } } }, "cleanup": { @@ -133,9 +221,15 @@ "additionalProperties": false, "required": ["storeRemoved", "messageFilesRemoved", "providerFilesUntouched"], "properties": { - "storeRemoved": { "type": "boolean" }, - "messageFilesRemoved": { "type": "boolean" }, - "providerFilesUntouched": { "type": "boolean" } + "storeRemoved": { + "type": "boolean" + }, + "messageFilesRemoved": { + "type": "boolean" + }, + "providerFilesUntouched": { + "type": "boolean" + } } } }, @@ -148,9 +242,185 @@ "verdict": { "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "NOT_AUTHORIZED", "n/a"] }, - "versionKnown": { "type": "boolean" }, - "identityHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + "versionKnown": { + "type": "boolean" + }, + "version": { + "type": "string" + }, + "identityHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "sourceIdentityHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "accepted": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + } + } + }, + "passProvider": { + "required": [ + "verdict", + "versionKnown", + "version", + "identityHash", + "sourceIdentityHash", + "accepted", + "completed" + ], + "properties": { + "verdict": { + "const": "PASS" + }, + "versionKnown": { + "const": true + }, + "accepted": { + "const": true + }, + "completed": { + "const": true + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "verdict": { + "const": "PASS" + }, + "mode": { + "const": "live-claude" + } + }, + "required": ["verdict", "mode"] + }, + "then": { + "required": ["head", "deliveries"], + "properties": { + "deliveries": { + "minItems": 1 + }, + "turnBudgets": { + "properties": { + "claudeAuthorized": { + "minimum": 1 + }, + "claudeSpent": { + "minimum": 1 + } + } + }, + "providers": { + "properties": { + "claude": { + "$ref": "#/definitions/passProvider" + } + } + } + } + } + }, + { + "if": { + "properties": { + "verdict": { + "const": "PASS" + }, + "mode": { + "const": "live-codex" + } + }, + "required": ["verdict", "mode"] + }, + "then": { + "required": ["head", "deliveries"], + "properties": { + "deliveries": { + "minItems": 1 + }, + "turnBudgets": { + "properties": { + "codexAuthorized": { + "minimum": 1 + }, + "codexSpent": { + "minimum": 1 + } + } + }, + "providers": { + "properties": { + "codex": { + "$ref": "#/definitions/passProvider" + } + } + } + } + } + }, + { + "if": { + "properties": { + "verdict": { + "const": "PASS" + }, + "mode": { + "const": "deterministic" + } + }, + "required": ["verdict", "mode"] + }, + "then": { + "required": ["head", "deliveries"], + "properties": { + "matrix": { + "minItems": 18, + "not": { + "contains": { + "type": "object", + "required": ["result"], + "properties": { + "result": { + "enum": ["fail", "n/a"] + } + } + } + } + }, + "deliveries": { + "minItems": 1 + }, + "turnBudgets": { + "properties": { + "claudeSpent": { + "minimum": 1 + }, + "codexSpent": { + "minimum": 1 + } + } + }, + "providers": { + "properties": { + "claude": { + "$ref": "#/definitions/passProvider" + }, + "codex": { + "$ref": "#/definitions/passProvider" + } + } + } + } } } - } + ] } diff --git a/packages/terminal-tmux/poc/repl/report.ts b/packages/terminal-tmux/poc/repl/report.ts index e8f4c812a..a7029fbb8 100644 --- a/packages/terminal-tmux/poc/repl/report.ts +++ b/packages/terminal-tmux/poc/repl/report.ts @@ -8,9 +8,15 @@ * path, argv, environment, tmux identifier, credential, socket, token or raw * native identity. An identity is carried only as a hash. * - * The schema is the disclosure boundary. A report that tried to carry a - * forbidden field would fail validation here, so the builder and the validator - * are kept together. + * The schema is the disclosure boundary *and* the proof boundary. A `PASS` is + * only schema-valid with the full evidence its mode requires: a `live-claude` or + * `live-codex` report needs that provider passing with a known version, hashed + * native and source identities, observed acceptance and completion, a spent turn, + * an attempted delivery, and exact base and head commits; a `deterministic` PASS + * additionally needs the RP matrix with no failed or skipped row. The full POC + * decision is the conjunction of the offline matrix and both provider documents + * passing in their own authorized runs. A report that claims `PASS` without its + * evidence, or carries a forbidden field, fails validation here. */ import { Ajv } from "ajv"; @@ -56,7 +62,16 @@ export interface ReportCounters { export interface ProviderReport { readonly verdict: ProviderVerdict; readonly versionKnown: boolean; + /** The observed provider version, when known. Required for a PASS. */ + readonly version?: string; + /** A hash of the native identity. Required for a PASS. */ readonly identityHash?: string; + /** A hash of the source-file identity. Required for a PASS. */ + readonly sourceIdentityHash?: string; + /** Whether the exact user event was observed. Required for a PASS. */ + readonly accepted?: boolean; + /** Whether an explicit completion boundary was observed. Required for a PASS. */ + readonly completed?: boolean; } export interface DeliveryEvidence { @@ -91,7 +106,7 @@ export interface TerminalReplReport { readonly runtime: string; readonly detail?: string; readonly base: { readonly sha: string; readonly parent?: string }; - readonly head?: { readonly sha?: string }; + readonly head?: { readonly sha: string }; readonly providers: { readonly claude: ProviderReport; readonly codex: ProviderReport }; readonly turnBudgets: TurnBudgets; readonly matrix: readonly MatrixEntry[]; diff --git a/packages/terminal-tmux/poc/repl/state.ts b/packages/terminal-tmux/poc/repl/state.ts index 42bd59c42..758218138 100644 --- a/packages/terminal-tmux/poc/repl/state.ts +++ b/packages/terminal-tmux/poc/repl/state.ts @@ -67,6 +67,8 @@ export interface NormalizedEvent { readonly identity: string; /** The relevant text, or the empty string for a completion boundary. */ readonly text: string; + /** The provider's own turn identity, when it groups output by one. */ + readonly turn?: string; } /** One REPL message and everything the store retains about it. */ @@ -248,6 +250,7 @@ function reduceOne(role: RoleState, action: RoleAction): RoleState { key: action.eventKey, identity: action.identity, text: action.text, + turn: action.turn, }, ); case "AssistantObserved": @@ -256,13 +259,20 @@ function reduceOne(role: RoleState, action: RoleAction): RoleState { key: action.eventKey, identity: action.identity, text: action.text, + turn: action.turn, }); case "AssistantCompleted": return recordEvent( mapMessage({ ...role, inFlight: undefined }, action.id, (message) => message.state === "accepted" ? { ...message, state: "completed" } : message, ), - { kind: "turn-completed", key: action.eventKey, identity: action.identity, text: "" }, + { + kind: "turn-completed", + key: action.eventKey, + identity: action.identity, + text: "", + turn: action.turn, + }, ); case "ObserverAdvanced": return { ...role, cursor: action.cursor, observerSource: action.source }; diff --git a/packages/terminal-tmux/poc/repl/store.ts b/packages/terminal-tmux/poc/repl/store.ts index 6169a0f86..66cedd926 100644 --- a/packages/terminal-tmux/poc/repl/store.ts +++ b/packages/terminal-tmux/poc/repl/store.ts @@ -7,23 +7,23 @@ * crash never leaves a half-written record in the log. The directory is mode * `0700` and each record `0600`. * - * Restart restores the store by replaying that log. The log is required to be - * contiguous from zero with no duplicate and no malformed record; anything else - * is a conflicting history and is refused rather than read past, because a store - * that guessed at a gap would resume a run it cannot actually account for. + * Restart restores the store by replaying that log. Every record's complete shape + * and legal type are parsed with a schema — a known action missing a member, an + * unknown type, a record whose file name disagrees with its sequence number, a + * gap, a duplicate, or any conflicting history is refused rather than read past, + * because a store that guessed would resume a run it cannot account for. * * StarFX was evaluated as an implementation aid and deliberately not adopted: * the reducer and the log are small enough to own directly, and the POC must add * no production dependency. */ -import { createSignal, ensure, resource } from "effection"; +import { createSignal, ensure, resource, until } from "effection"; import type { Operation, Stream } from "effection"; import { ensureDir, exists, readdir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; import { chmod, rename } from "node:fs/promises"; import { join } from "node:path"; -import { until } from "effection"; -import { ACTION_TYPES } from "./actions.ts"; +import { z } from "zod"; import type { ReplAction } from "./actions.ts"; import { emptyState, reduce } from "./state.ts"; import type { ReplState } from "./state.ts"; @@ -54,13 +54,110 @@ export interface ReplStore { readonly states: Stream; } -const ACTION_TYPE_SET: ReadonlySet = new Set(ACTION_TYPES); +const IdentitySchema = z.object({ + provider: z.enum(["claude", "codex"]), + id: z.string(), +}); + +const ReadinessSchema = z.enum(["unknown", "converging", "ready", "busy", "unavailable"]); + +/** The complete shape of every action, so a malformed one is refused. */ +const ActionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("ReplOpened"), replSession: z.string() }), + z.object({ + type: z.literal("RoleBound"), + key: z.string(), + role: z.string(), + issue: z.string(), + identity: IdentitySchema, + paneGeneration: z.number().int(), + }), + z.object({ + type: z.literal("MessageQueued"), + key: z.string(), + id: z.string(), + text: z.string(), + marker: z.string(), + }), + z.object({ type: z.literal("TerminalObserved"), key: z.string(), readiness: ReadinessSchema }), + z.object({ type: z.literal("ProviderBusy"), key: z.string() }), + z.object({ type: z.literal("ProviderIdle"), key: z.string() }), + z.object({ type: z.literal("ConvergenceStarted"), key: z.string(), id: z.string() }), + z.object({ + type: z.literal("ConvergenceInvalidated"), + key: z.string(), + id: z.string(), + reason: z.string(), + }), + z.object({ type: z.literal("AttemptStarted"), key: z.string(), id: z.string() }), + z.object({ + type: z.literal("AttemptDeclined"), + key: z.string(), + id: z.string(), + reason: z.string(), + }), + z.object({ + type: z.literal("AttemptUncertain"), + key: z.string(), + id: z.string(), + reason: z.string(), + }), + z.object({ + type: z.literal("UserAccepted"), + key: z.string(), + id: z.string(), + eventKey: z.string(), + identity: z.string(), + text: z.string(), + turn: z.string().optional(), + }), + z.object({ + type: z.literal("AssistantObserved"), + key: z.string(), + eventKey: z.string(), + identity: z.string(), + text: z.string(), + turn: z.string().optional(), + }), + z.object({ + type: z.literal("AssistantCompleted"), + key: z.string(), + id: z.string(), + eventKey: z.string(), + identity: z.string(), + turn: z.string().optional(), + }), + z.object({ + type: z.literal("ObserverAdvanced"), + key: z.string(), + cursor: z.number().int(), + source: z.string(), + }), + z.object({ type: z.literal("PaneUnavailable"), key: z.string(), reason: z.string() }), + z.object({ type: z.literal("ObserverRefused"), key: z.string(), reason: z.string() }), + z.object({ type: z.literal("ReplClosed") }), +]); + +const EntrySchema = z.object({ + seq: z.number().int().nonnegative(), + action: ActionSchema, +}); + +// The schema is held to the declared action union rather than the union being +// read off it: a change to either the schema stops compiling here. +const _actionSchema: z.ZodType = ActionSchema; /** A stored entry's file name: zero-padded so a lexical sort is numeric. */ function recordName(seq: number): string { return `${String(seq).padStart(6, "0")}.json`; } +/** The sequence number a record file name encodes, or NaN when it encodes none. */ +function seqFromName(name: string): number { + const digits = name.slice(0, -".json".length); + return /^\d+$/.test(digits) ? Number(digits) : Number.NaN; +} + /** * Open one REPL store rooted at `dir`, restoring any retained log. * @@ -133,14 +230,17 @@ function parseEntry(text: string, name: string): StoredAction { } catch { throw new ReplStoreError(`a record that is not JSON (${name})`); } - if (!isRecord(value) || typeof value.seq !== "number" || !Number.isInteger(value.seq)) { - throw new ReplStoreError(`a record with no integer sequence (${name})`); + const parsed = EntrySchema.safeParse(value); + if (!parsed.success) { + throw new ReplStoreError( + `a malformed record (${name}): ${parsed.error.issues[0]?.message ?? "invalid"}`, + ); } - const action = value.action; - if (!isRecord(action) || typeof action.type !== "string" || !ACTION_TYPE_SET.has(action.type)) { - throw new ReplStoreError(`a record with no known action type (${name})`); + const nameSeq = seqFromName(name); + if (Number.isNaN(nameSeq) || nameSeq !== parsed.data.seq) { + throw new ReplStoreError(`a record whose file name disagrees with its sequence (${name})`); } - return { seq: value.seq, action: action as unknown as ReplAction }; + return { seq: parsed.data.seq, action: parsed.data.action }; } /** Write one record through a staged file and a rename, at mode `0600`. */ @@ -156,7 +256,3 @@ function* persist(dir: string, entry: StoredAction): Operation { export function purgeStore(dir: string): Operation { return rm(dir, { recursive: true, force: true }); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts index ae30b2672..4ec01ceea 100644 --- a/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts +++ b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts @@ -10,10 +10,14 @@ * If the algorithm ever pastes while either is true, the fake records it and the * test fails. * + * It also models the two ways the final guard can go wrong on a real server: a + * command that fails outright (declined) and one that half-succeeds so bytes may + * have gone but the whole delivery is unproved (uncertain). A test arms either. + * * The synthetic-file helpers write append-only provider records in the exact * shapes the two observers accept, so a test can build acceptance, completion, a - * partial tail, truncation, rotation, an ambiguous identity, a wrong identity and - * an unsupported shape without a real agent. + * partial tail, truncation, rotation, an ambiguous identity, a wrong identity, a + * wrong project and an unsupported shape without a real agent. */ import { until } from "effection"; @@ -45,6 +49,8 @@ export interface FakePane { readonly deliveries: readonly FakeDelivery[]; /** How many times the guard declined a paste. */ readonly declines: number; + /** How many named buffers are still loaded (a delivery removes its own). */ + pendingBuffers(): number; /** Bump the event epoch: any observable pane event. */ event(): void; /** A visible client acted (a person moved the cursor, scrolled, typed). */ @@ -61,10 +67,12 @@ export interface FakePane { replace(): void; /** The pane's process exited. */ kill(): void; - /** Run `mutate` the next time the fake crosses a barrier. */ - armBarrier(mutate: () => void): void; + /** Run `mutate` the next time the fake crosses a barrier (may do async work). */ + armBarrier(mutate: () => Operation): void; /** Run `mutate` the next time a buffer is loaded (just before the guarded paste). */ armLoad(mutate: () => void): void; + /** Make the next guarded paste fail its server-side command with this outcome. */ + armGuardFailure(kind: "declined" | "uncertain"): void; } /** Options for a fresh fake pane. */ @@ -89,8 +97,9 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { let declines = 0; const deliveries: FakeDelivery[] = []; const buffers = new Map(); - let barrierTrap: (() => void) | undefined; + let barrierTrap: (() => Operation) | undefined; let loadTrap: (() => void) | undefined; + let guardFailure: "declined" | "uncertain" | undefined; function snapshot(): PaneSnapshot { return { @@ -115,7 +124,7 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { const trap = barrierTrap; barrierTrap = undefined; if (trap !== undefined) { - trap(); + yield* trap(); } // A barrier is an acknowledged round-trip; the yield models that wait // without changing any structural fact by itself. @@ -131,6 +140,10 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { buffers.set(buffer, bytes); }, // deno-lint-ignore require-yield + *deleteBuffer(buffer): Operation { + buffers.delete(buffer); + }, + // deno-lint-ignore require-yield *guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation { // The recheck and the paste happen with no suspension between them: the // current state is read and compared, and a matching guard pastes at once. @@ -143,6 +156,16 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { declines += 1; return { outcome: "declined", reason: "pane-unavailable" }; } + const failure = guardFailure; + guardFailure = undefined; + if (failure === "declined") { + declines += 1; + return { outcome: "declined", reason: "tmux-command-failed" }; + } + if (failure === "uncertain") { + // The buffer pasted but the submit key could not be proved sent. + return { outcome: "uncertain", reason: "submit-unacknowledged" }; + } const bytes = buffers.get(delivery.buffer) ?? ""; deliveries.push({ buffer: delivery.buffer, bytes, whileBusy: busy, whileManual: manual }); return { outcome: "pasted" }; @@ -157,6 +180,9 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { get declines() { return declines; }, + pendingBuffers() { + return buffers.size; + }, event() { epoch += 1; }, @@ -194,32 +220,42 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { armLoad(mutate) { loadTrap = mutate; }, + armGuardFailure(kind) { + guardFailure = kind; + }, }; } // --- Synthetic provider records ------------------------------------------------ /** A Claude `user` record carrying the exact attempted text under `sessionId`. */ -export function claudeUser(sessionId: string, text: string): string { +export function claudeUser(sessionId: string, text: string, turn?: string): string { return line({ type: "user", sessionId, + ...(turn === undefined ? {} : { requestId: turn }), message: { role: "user", content: [{ type: "text", text }] }, }); } -/** A Claude `assistant` record. */ -export function claudeAssistant(sessionId: string, text: string): string { +/** A Claude `assistant` record, optionally grouped under a turn's `requestId`. */ +export function claudeAssistant(sessionId: string, text: string, turn?: string): string { return line({ type: "assistant", sessionId, + ...(turn === undefined ? {} : { requestId: turn }), message: { role: "assistant", content: [{ type: "text", text }] }, }); } -/** The explicit Claude completion boundary. */ -export function claudeResult(sessionId: string): string { - return line({ type: "result", sessionId, subtype: "success" }); +/** The explicit Claude completion boundary, optionally grouped under a turn. */ +export function claudeResult(sessionId: string, turn?: string): string { + return line({ + type: "result", + sessionId, + subtype: "success", + ...(turn === undefined ? {} : { requestId: turn }), + }); } /** A Claude `user` record whose message has no readable text: an unsupported shape. */ @@ -227,9 +263,12 @@ export function claudeUnsupported(sessionId: string): string { return line({ type: "user", sessionId, message: { role: "user" } }); } -/** The Codex `session_meta` header naming the thread identity. */ -export function codexMeta(id: string): string { - return line({ type: "session_meta", payload: { id } }); +/** The Codex `session_meta` header naming the thread identity and its project. */ +export function codexMeta(id: string, project?: string): string { + return line({ + type: "session_meta", + payload: { id, ...(project === undefined ? {} : { cwd: project }) }, + }); } /** A Codex `user_message` event carrying the exact attempted text. */ diff --git a/packages/terminal-tmux/tests/repl-poc.test.ts b/packages/terminal-tmux/tests/repl-poc.test.ts index d1957b44d..3fc47e39e 100644 --- a/packages/terminal-tmux/tests/repl-poc.test.ts +++ b/packages/terminal-tmux/tests/repl-poc.test.ts @@ -6,21 +6,23 @@ * algorithm reads, and synthetic append-only files supply the provider evidence * the observer reads. The fake also holds the hidden truth — actually busy, * actually typed-into — that only these assertions see, so a paste admitted while - * either was true is caught. + * either was true is caught, and it models the two ways the final guard can go + * wrong on a real server (a declined command and an unacknowledged submit). * - * Every success is a parsed record, an explicit event, or a counted delivery; - * elapsed time proves nothing here. The final row builds the - * `terminal-repl-poc-report.v1` artifact and validates it against the checked-in - * schema. + * Beyond the frozen matrix, supporting rows exercise the boundaries the live + * worker relies on: provider state folded into convergence, the guard's command + * outcome, project-scoped location, turn grouping, per-provider authorization, + * strict persisted-action parsing, and the report's PASS gate. * - * The whole suite is portable: it uses no tmux, no CLI subprocess, and no - * runtime-specific API, so it runs under Deno, Node and Bun like any other file. + * Every success is a parsed record, an explicit event, or a counted delivery; + * elapsed time proves nothing here. The suite is portable — no tmux, no CLI + * subprocess, no runtime-specific API — so it runs under Deno, Node and Bun. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { useTempDirectory } from "@executablemd/test-support/temp"; -import { ensureDir, exists, readTextFile } from "@effectionx/fs"; +import { ensureDir, exists, readTextFile, writeTextFile } from "@effectionx/fs"; import { chmod } from "node:fs/promises"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -33,10 +35,11 @@ import { settleUnconfirmed, } from "../poc/repl/controller.ts"; import type { DeliveryOptions, ObserverSource } from "../poc/repl/controller.ts"; -import { purgeStore, useReplStore } from "../poc/repl/store.ts"; +import { purgeStore, ReplStoreError, useReplStore } from "../poc/repl/store.ts"; import type { ReplStore } from "../poc/repl/store.ts"; import { claudeParser } from "../poc/repl/claude-observer.ts"; import { codexParser } from "../poc/repl/codex-observer.ts"; +import { gatesSatisfied, runLiveProof } from "../poc/repl/live-supervisor.ts"; import type { Provider, ReplState } from "../poc/repl/state.ts"; import type { ProviderParser } from "../poc/repl/observer.ts"; import { identityHash, REPORT_SCHEMA, validateReport, zeroCounters } from "../poc/repl/report.ts"; @@ -45,42 +48,43 @@ import type { MatrixEntry, ReportCounters, RestartEvidence, + TerminalReplReport, } from "../poc/repl/report.ts"; import { appendPartial, appendRecords, + claudeAssistant, + claudeResult, claudeSessionPath, claudeUnsupported, + claudeUser, + codexAgent, + codexComplete, + codexMeta, codexRolloutPath, codexUnsupported, + codexUser, createFakePane, rotateFile, truncateFile, writeRecords, } from "./fixtures/repl-poc/fake-terminal.ts"; import type { FakePane } from "./fixtures/repl-poc/fake-terminal.ts"; -import { - claudeAssistant, - claudeResult, - claudeUser, - codexAgent, - codexComplete, - codexMeta, - codexUser, -} from "./fixtures/repl-poc/fake-terminal.ts"; /** The exact base the POC was implemented from, recorded in the report. */ const BASE_SHA = "97fda6aa7b5f85db747c066898fd3ef3c6d1dbeb"; +/** A representative head, only for proving the schema accepts a full PASS. */ +const HEAD_SHA = "0123456789abcdef0123456789abcdef01234567"; /** One provider's record shapes, so a scenario can run against either agent. */ interface ProviderKit { readonly provider: Provider; readonly parser: ProviderParser; path(directory: string, id: string): string; - idle(id: string): string[]; - user(id: string, text: string): string; - assistant(id: string, text: string): string; - complete(id: string): string; + idle(id: string, project: string): string[]; + user(id: string, text: string, turn?: string): string; + assistant(id: string, text: string, turn?: string): string; + complete(id: string, turn?: string): string; unsupported(id: string): string; } @@ -89,9 +93,9 @@ const CLAUDE_KIT: ProviderKit = { parser: claudeParser, path: (directory, id) => claudeSessionPath(directory, id), idle: () => [], - user: (id, text) => claudeUser(id, text), - assistant: (id, text) => claudeAssistant(id, text), - complete: (id) => claudeResult(id), + user: (id, text, turn) => claudeUser(id, text, turn), + assistant: (id, text, turn) => claudeAssistant(id, text, turn), + complete: (id, turn) => claudeResult(id, turn), unsupported: (id) => claudeUnsupported(id), }; @@ -99,7 +103,7 @@ const CODEX_KIT: ProviderKit = { provider: "codex", parser: codexParser, path: (directory) => codexRolloutPath(directory, "main"), - idle: (id) => [codexMeta(id)], + idle: (id, project) => [codexMeta(id, project)], user: (_id, text) => codexUser(text), assistant: (_id, text) => codexAgent(text), complete: () => codexComplete(), @@ -112,6 +116,7 @@ interface Bag { readonly storeDir: string; readonly providerDir: string; readonly messageDir: string; + readonly project: string; readonly store: ReplStore; readonly pane: FakePane; readonly identity: { readonly provider: Provider; readonly id: string }; @@ -149,6 +154,7 @@ function scaffold( const storeDir = join(root, `store-${suffix}`); const providerDir = join(root, `provider-${suffix}`); const messageDir = join(root, `messages-${suffix}`); + const project = join(root, `project-${suffix}`); yield* ensureDir(providerDir); yield* ensureDir(messageDir); yield* until(chmod(messageDir, 0o700)); @@ -162,10 +168,11 @@ function scaffold( storeDir, providerDir, messageDir, + project, store, pane, identity, - observer: { parser: kit.parser, directory: providerDir }, + observer: { parser: kit.parser, directory: providerDir, project }, options: { messageDir, bracketedPaste: true, submitKey: "Enter" }, path: kit.path(providerDir, identity.id), }; @@ -184,7 +191,7 @@ function bind(bag: Bag, options: { readonly generation?: number } = {}): Operati identity: bag.identity, paneGeneration: options.generation ?? 1, }); - yield* writeRecords(bag.path, bag.kit.idle(bag.identity.id)); + yield* writeRecords(bag.path, bag.kit.idle(bag.identity.id, bag.project)); })(); } @@ -217,8 +224,13 @@ function messageState(store: ReplStore, key: string, id: string): string { describe("issue #774 — black-box REPL messaging POC", () => { it("RP1 — an idle pane accepts exactly one literal message and completes", function* () { const root = yield* useTempDirectory("xmd-repl-rp1-"); - for (const kit of [CLAUDE_KIT, CODEX_KIT]) { - const bag = yield* scaffold(kit, root); + // Claude carries an explicit turn identity; Codex is a linear thread. + const cases: { kit: ProviderKit; turn: string | undefined }[] = [ + { kit: CLAUDE_KIT, turn: "req-1" }, + { kit: CODEX_KIT, turn: undefined }, + ]; + for (const scenario of cases) { + const bag = yield* scaffold(scenario.kit, root); yield* bind(bag); const message = yield* queue(bag); @@ -235,16 +247,14 @@ describe("issue #774 — black-box REPL messaging POC", () => { tally.admittedDeliveries += 1; deliveries.push({ messageHash: attempt.hash, byteCount: attempt.byteCount }); } - // Exactly one paste reached the pane, and not while busy or manual. expect(bag.pane.deliveries.length).toEqual(1); expect(bag.pane.deliveries[0]?.whileBusy).toEqual(false); expect(bag.pane.deliveries[0]?.whileManual).toEqual(false); - // The intended file records the exact user event and a complete assistant turn. yield* appendRecords(bag.path, [ - kit.user(bag.identity.id, message.text), - kit.assistant(bag.identity.id, "Working on it."), - kit.complete(bag.identity.id), + scenario.kit.user(bag.identity.id, message.text, scenario.turn), + scenario.kit.assistant(bag.identity.id, "Working on it.", scenario.turn), + scenario.kit.complete(bag.identity.id, scenario.turn), ]); yield* observeStep(bag.store, bag.identity.id, bag.observer); expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("completed"); @@ -260,7 +270,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { yield* bind(bag); const message = yield* queue(bag); - // The provider is mid-turn: a user event with no completion is an open turn. yield* appendRecords(bag.path, [codexUser("someone else's turn"), codexAgent("thinking")]); bag.pane.setBusy(true); const attempt = yield* attemptStep( @@ -275,7 +284,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(bag.pane.deliveries.length).toEqual(0); expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); - // The turn completes; the pane is idle; the message is admitted now. yield* appendRecords(bag.path, [codexComplete()]); bag.pane.setBusy(false); yield* observeStep(bag.store, bag.identity.id, bag.observer); @@ -330,10 +338,9 @@ describe("issue #774 — black-box REPL messaging POC", () => { tally.admittedDeliveries += 1; } - // Each file records only its own message. yield* appendRecords(claude.path, [ - claude.kit.user(claude.identity.id, claudeMessage.text), - claude.kit.complete(claude.identity.id), + claude.kit.user(claude.identity.id, claudeMessage.text, "req-c"), + claude.kit.complete(claude.identity.id, "req-c"), ]); yield* appendRecords(codex.path, [ codex.kit.user(codex.identity.id, codexMessage.text), @@ -376,7 +383,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { if (one.outcome === "pasted") { tally.admittedDeliveries += 1; } - // The second cannot begin: a message is in flight. const blocked = yield* attemptStep( bag.store, bag.identity.id, @@ -387,7 +393,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(blocked.outcome).toEqual("skipped"); expect(bag.pane.deliveries.length).toEqual(1); - // The first completes; only then does the second go. yield* appendRecords(bag.path, [codexUser(first.text), codexComplete()]); yield* observeStep(bag.store, bag.identity.id, bag.observer); expect(messageState(bag.store, bag.identity.id, first.id)).toEqual("completed"); @@ -440,13 +445,10 @@ describe("issue #774 — black-box REPL messaging POC", () => { if (attempt.outcome === "pasted") { tally.admittedDeliveries += 1; } - // The bytes the pane received are exactly the bytes queued — nothing was - // interpreted by a shell or a tmux argument vector. expect(bag.pane.deliveries[0]?.bytes).toEqual(text); yield* appendRecords(bag.path, [codexUser(text), codexComplete()]); yield* observeStep(bag.store, bag.identity.id, bag.observer); - // The provider event carries the exact bytes, so acceptance and completion settled. expect(messageState(bag.store, bag.identity.id, id)).toEqual("completed"); expect( record( @@ -463,7 +465,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { yield* bind(bag); const message = yield* queue(bag); - // Restart: a second store handle replays the persisted log. const restarted = yield* useReplStore(bag.storeDir); const settled = yield* reconcileRestart(restarted); expect(settled).toEqual(0); @@ -509,7 +510,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { } expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); - // Restart after the attempt but before acceptance: the outcome is uncertain. const restarted = yield* useReplStore(bag.storeDir); const settled = yield* reconcileRestart(restarted); expect(settled).toEqual(1); @@ -517,7 +517,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { tally.uncertain += 1; expect(messageState(restarted, bag.identity.id, message.id)).toEqual("uncertain"); - // A fresh pane for the restarted controller: it must not paste again. const restartedPane = createFakePane(); const again = yield* attemptStep( restarted, @@ -530,7 +529,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(again.outcome).toEqual("skipped"); expect(restartedPane.deliveries.length).toEqual(0); - // A later exact user event resolves the uncertainty without a new paste. yield* appendRecords(bag.path, [codexUser(message.text), codexComplete()]); yield* observeStep(restarted, bag.identity.id, bag.observer); expect(messageState(restarted, bag.identity.id, message.id)).toEqual("completed"); @@ -607,11 +605,9 @@ describe("issue #774 — black-box REPL messaging POC", () => { tally.admittedDeliveries += 1; } - // Consume the idle baseline first, so the cursor sits at the file's end. yield* observeStep(bag.store, bag.identity.id, bag.observer); const cursorBefore = role(bag.store.state(), bag.identity.id).cursor; - // A half-written user record: no newline yet. The cursor must not move. const full = codexUser(message.text); const partial = full.slice(0, Math.floor(full.length / 2)); yield* appendPartial(bag.path, partial); @@ -619,7 +615,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(role(bag.store.state(), bag.identity.id).cursor).toEqual(cursorBefore); expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); - // Complete the record: it emits exactly once. yield* appendPartial(bag.path, full.slice(partial.length)); yield* appendRecords(bag.path, [codexComplete()]); yield* observeStep(bag.store, bag.identity.id, bag.observer); @@ -642,8 +637,8 @@ describe("issue #774 — black-box REPL messaging POC", () => { const bag = yield* scaffold(CODEX_KIT, root); yield* bind(bag); const message = yield* queue(bag); - // A person types between convergence and the final guard: the load hook - // fires just before the guarded paste and moves client activity. + // A person types between convergence and the final guard, without changing + // the pane's PID: the load hook fires just before the guarded paste. bag.pane.armLoad(() => { bag.pane.setManual(true); bag.pane.clientActivity(); @@ -659,13 +654,12 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(attempt.outcome).toEqual("declined"); expect(bag.pane.deliveries.length).toEqual(0); expect(bag.pane.declines).toEqual(1); - // The message is queued again, not lost. expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); expect( record( "RP10", true, - "activity between convergence and the guard declined the paste; zero bytes sent", + "same-PID manual activity between convergence and the guard declined the paste; zero bytes sent", ), ).toEqual(true); }); @@ -702,7 +696,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { const bag = yield* scaffold(CODEX_KIT, root); yield* bind(bag, { generation: 1 }); const message = yield* queue(bag); - // The pane is replaced: a fresh generation the role never bound to. bag.pane.replace(); const attempt = yield* attemptStep( bag.store, @@ -728,9 +721,9 @@ describe("issue #774 — black-box REPL messaging POC", () => { const bag = yield* scaffold(CODEX_KIT, root); yield* bind(bag); const message = yield* queue(bag); - // A second rollout file names the same identity: now two files match. + // A second rollout for the same identity and project: now two files match. const second = codexRolloutPath(bag.providerDir, "duplicate"); - yield* writeRecords(second, [codexMeta(bag.identity.id)]); + yield* writeRecords(second, [codexMeta(bag.identity.id, bag.project)]); const attempt = yield* attemptStep( bag.store, bag.identity.id, @@ -746,7 +739,11 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(bag.pane.deliveries.length).toEqual(0); expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); expect( - record("RP13", true, "two files for one identity refused both observation and delivery"), + record( + "RP13", + true, + "two files for one identity and project refused both observation and delivery", + ), ).toEqual(true); }); @@ -771,7 +768,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { const cursorAfterAccept = role(bag.store.state(), bag.identity.id).cursor; expect(cursorAfterAccept > 0).toEqual(true); - // Truncate below the cursor: the observer refuses rather than rewinding. yield* truncateFile(bag.path, cursorAfterAccept - 5); const truncated = yield* observeStep(bag.store, bag.identity.id, bag.observer); expect(truncated.outcome).toEqual("refused"); @@ -781,9 +777,8 @@ describe("issue #774 — black-box REPL messaging POC", () => { tally.refusals += 1; expect(role(bag.store.state(), bag.identity.id).cursor).toEqual(cursorAfterAccept); - // Rotate the file to a new inode: a replacement is refused, not read. yield* rotateFile(bag.path, [ - codexMeta(bag.identity.id), + codexMeta(bag.identity.id, bag.project), codexUser(message.text), codexComplete(), ]); @@ -815,7 +810,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { if (attempt.outcome === "pasted") { tally.admittedDeliveries += 1; } - // A relevant record whose required member is missing is unsupported. yield* appendRecords(bag.path, [kit.unsupported(bag.identity.id)]); const observed = yield* observeStep(bag.store, bag.identity.id, bag.observer); expect(observed.outcome).toEqual("refused"); @@ -823,7 +817,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(observed.refusal).toEqual("unsupported-shape"); } tally.refusals += 1; - // Nothing was accepted, and the cursor did not move past the bad record. expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); } expect( @@ -852,7 +845,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { if (attempt.outcome === "pasted") { tally.admittedDeliveries += 1; } - // No exact user event ever appears; a bounded observation shows nothing. yield* observeStep(bag.store, bag.identity.id, bag.observer); expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); const settled = yield* settleUnconfirmed(bag.store, bag.identity.id, "no-acceptance"); @@ -860,7 +852,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { tally.uncertain += 1; expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("uncertain"); - // It is not retried: a further attempt step delivers nothing new. const again = yield* attemptStep( bag.store, bag.identity.id, @@ -878,7 +869,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { it("RP17 — wrong evidence never settles a message as accepted", function* () { const root = yield* useTempDirectory("xmd-repl-rp17-"); - // (a) A marker under another identity: the observer refuses it. const other = yield* scaffold(CLAUDE_KIT, root); yield* bind(other); const otherMessage = yield* queue(other); @@ -893,8 +883,9 @@ describe("issue #774 — black-box REPL messaging POC", () => { if (attemptA.outcome === "pasted") { tally.admittedDeliveries += 1; } - // A user record under a different sessionId, in the intended file. - yield* appendRecords(other.path, [claudeUser("someone-else-entirely", otherMessage.text)]); + yield* appendRecords(other.path, [ + claudeUser("someone-else-entirely", otherMessage.text, "req-x"), + ]); const refused = yield* observeStep(other.store, other.identity.id, other.observer); expect(refused.outcome).toEqual("refused"); if (refused.outcome === "refused") { @@ -905,7 +896,6 @@ describe("issue #774 — black-box REPL messaging POC", () => { "attempt-started", ); - // (b) Different text under the intended identity: no acceptance. const bag = yield* scaffold(CODEX_KIT, root); yield* bind(bag); const message = yield* queue(bag); @@ -932,7 +922,7 @@ describe("issue #774 — black-box REPL messaging POC", () => { ).toEqual(true); }); - it("RP18 — ownership and cleanup remove only owned state and never touch provider files", function* () { + it("RP18 — ownership and cleanup remove owned state across success, cancellation and partial acquisition", function* () { const root = yield* useTempDirectory("xmd-repl-rp18-"); const bag = yield* scaffold(CODEX_KIT, root); yield* bind(bag); @@ -948,12 +938,30 @@ describe("issue #774 — black-box REPL messaging POC", () => { if (attempt.outcome === "pasted") { tally.admittedDeliveries += 1; } - // The private message file is removed once the attempt settled. + // On success, the private message file and the tmux buffer are both gone. const messageFile = join(bag.messageDir, `${message.id}.msg`); cleanup.messageFilesRemoved = !(yield* exists(messageFile)); + expect(bag.pane.pendingBuffers()).toEqual(0); - // Observing does not write: the provider file bytes are unchanged. + // A declined attempt (partial acquisition: file written, guard declined) + // still removes the file and the buffer. + const second = yield* queue(bag); yield* appendRecords(bag.path, [codexUser(message.text), codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + bag.pane.armGuardFailure("declined"); + const declined = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(declined.outcome).toEqual("declined"); + expect(yield* exists(join(bag.messageDir, `${second.id}.msg`))).toEqual(false); + expect(bag.pane.pendingBuffers()).toEqual(0); + + // Observing does not write: the provider file bytes are unchanged. const before = yield* readTextFile(bag.path); yield* observeStep(bag.store, bag.identity.id, bag.observer); const after = yield* readTextFile(bag.path); @@ -971,12 +979,352 @@ describe("issue #774 — black-box REPL messaging POC", () => { record( "RP18", true, - "message file removed, provider file byte-identical after observe, store dir purged, provider file survived", + "message file and buffer removed on success and on a declined partial attempt; provider file byte-identical; store purged; provider file survived", ), ).toEqual(true); }); - it("produces a valid terminal-repl-poc-report.v1 with every row passing", function* () { + // --- Supporting rows for the boundaries the live worker relies on ------------ + + it("provider state is part of convergence: a turn opening during the barrier refuses", function* () { + const root = yield* useTempDirectory("xmd-repl-barrier-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + // A provider turn appears during the acknowledged barrier, exactly the race + // the Architect reported. Convergence samples the provider after the barrier + // and must refuse rather than paste. + bag.pane.armBarrier(() => + appendRecords(bag.path, [codexUser("an interleaved turn"), codexAgent("busy")]), + ); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + tally.convergenceAttempts += 1; + expect(attempt.outcome).toEqual("not-ready"); + expect(bag.pane.deliveries.length).toEqual(0); + expect(bag.pane.deliveries.every((delivery) => !delivery.whileBusy)).toEqual(true); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + }); + + it("pane output or a mode change during the barrier refuses", function* () { + const root = yield* useTempDirectory("xmd-repl-panechange-"); + for (const disturb of [ + (pane: FakePane) => pane.output(), + (pane: FakePane) => pane.setMode("copy"), + ]) { + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + yield* queue(bag); + bag.pane.armBarrier(() => { + disturb(bag.pane); + return until(Promise.resolve()); + }); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("not-ready"); + expect(bag.pane.deliveries.length).toEqual(0); + } + }); + + it("the guard's command outcome is honored: a failed command declines, an unacknowledged submit is uncertain", function* () { + const root = yield* useTempDirectory("xmd-repl-guard-"); + const declinedBag = yield* scaffold(CODEX_KIT, root); + yield* bind(declinedBag); + const first = yield* queue(declinedBag); + declinedBag.pane.armGuardFailure("declined"); + const declined = yield* attemptStep( + declinedBag.store, + declinedBag.identity.id, + declinedBag.pane.probe, + declinedBag.observer, + declinedBag.options, + ); + expect(declined.outcome).toEqual("declined"); + expect(declinedBag.pane.deliveries.length).toEqual(0); + expect(messageState(declinedBag.store, declinedBag.identity.id, first.id)).toEqual("queued"); + + const uncertainBag = yield* scaffold(CODEX_KIT, root); + yield* bind(uncertainBag); + const second = yield* queue(uncertainBag); + uncertainBag.pane.armGuardFailure("uncertain"); + const uncertain = yield* attemptStep( + uncertainBag.store, + uncertainBag.identity.id, + uncertainBag.pane.probe, + uncertainBag.observer, + uncertainBag.options, + ); + expect(uncertain.outcome).toEqual("uncertain"); + expect(uncertainBag.pane.deliveries.length).toEqual(0); + expect(messageState(uncertainBag.store, uncertainBag.identity.id, second.id)).toEqual( + "uncertain", + ); + // Not retried. + const again = yield* attemptStep( + uncertainBag.store, + uncertainBag.identity.id, + uncertainBag.pane.probe, + uncertainBag.observer, + uncertainBag.options, + ); + expect(again.outcome).toEqual("skipped"); + }); + + it("a source under the wrong project is not located", function* () { + const root = yield* useTempDirectory("xmd-repl-project-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + yield* queue(bag); + // The only file for this identity names a different project. + yield* writeRecords(bag.path, [codexMeta(bag.identity.id, `${bag.project}-elsewhere`)]); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("refused"); + if (attempt.outcome === "refused") { + expect(attempt.refusal).toEqual("not-found"); + } + expect(bag.pane.deliveries.length).toEqual(0); + }); + + it("Claude output and completion are grouped by turn identity", function* () { + const root = yield* useTempDirectory("xmd-repl-turn-"); + const bag = yield* scaffold(CLAUDE_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("pasted"); + // Accept under turn "t1"; a stray assistant output under "t2" must not be + // attributed to it, and only the "t1" completion completes the message. + yield* appendRecords(bag.path, [ + claudeUser(bag.identity.id, message.text, "t1"), + claudeAssistant(bag.identity.id, "unrelated other turn", "t2"), + claudeAssistant(bag.identity.id, "the real answer", "t1"), + claudeResult(bag.identity.id, "t1"), + ]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("completed"); + const events = role(bag.store.state(), bag.identity.id).events; + const strayAttributed = events.some( + (event) => event.kind === "assistant-output" && event.turn === "t2", + ); + expect(strayAttributed).toEqual(false); + }); + + it("per-provider authorization does not launch the other provider", function* () { + // Only Codex gates are set; a Claude proof still refuses without launching. + const codexOnly = { + XMD_TERMINAL_REPL_CODEX_PROOF: "1", + XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED: "2", + }; + expect(gatesSatisfied("codex", codexOnly)).toEqual(true); + expect(gatesSatisfied("claude", codexOnly)).toEqual(false); + const claudeReport = yield* runLiveProof("claude", codexOnly, BASE_SHA); + expect(claudeReport.verdict).toEqual("NOT_AUTHORIZED"); + expect(claudeReport.turnBudgets.claudeSpent).toEqual(0); + expect(claudeReport.turnBudgets.codexSpent).toEqual(0); + + const claudeOnly = { + XMD_TERMINAL_REPL_CLAUDE_PROOF: "1", + XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED: "1", + }; + expect(gatesSatisfied("claude", claudeOnly)).toEqual(true); + expect(gatesSatisfied("codex", claudeOnly)).toEqual(false); + const codexReport = yield* runLiveProof("codex", claudeOnly, BASE_SHA); + expect(codexReport.verdict).toEqual("NOT_AUTHORIZED"); + expect(codexReport.turnBudgets.codexSpent).toEqual(0); + }); + + it("a wrong turn value gate refuses: previous authorization does not count", function* () { + const wrongTurns = { + XMD_TERMINAL_REPL_CODEX_PROOF: "1", + XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED: "1", + }; + expect(gatesSatisfied("codex", wrongTurns)).toEqual(false); + const report = yield* runLiveProof("codex", wrongTurns, BASE_SHA); + expect(report.verdict).toEqual("NOT_AUTHORIZED"); + }); + + it("the store refuses malformed, mislabeled, gapped, duplicated and conflicting histories", function* () { + const root = yield* useTempDirectory("xmd-repl-store-"); + const write = (dir: string, name: string, body: unknown): Operation => + (function* (): Operation { + yield* ensureDir(dir); + yield* writeTextFile(join(dir, name), `${JSON.stringify(body)}\n`); + })(); + const refuses = (dir: string): Operation => + (function* (): Operation { + try { + yield* useReplStore(dir); + return false; + } catch (error) { + return error instanceof ReplStoreError; + } + })(); + + // A known action missing a required member. + const malformed = join(root, "malformed"); + yield* write(malformed, "000000.json", { seq: 0, action: { type: "RoleBound" } }); + expect(yield* refuses(malformed)).toEqual(true); + + // The file name disagrees with the record's sequence number. + const mislabeled = join(root, "mislabeled"); + yield* write(mislabeled, "000005.json", { seq: 0, action: { type: "ReplClosed" } }); + expect(yield* refuses(mislabeled)).toEqual(true); + + // A gap: sequence 0 then 2, no 1. + const gapped = join(root, "gapped"); + yield* write(gapped, "000000.json", { seq: 0, action: { type: "ReplClosed" } }); + yield* write(gapped, "000002.json", { seq: 2, action: { type: "ReplClosed" } }); + expect(yield* refuses(gapped)).toEqual(true); + + // An unknown action type. + const unknown = join(root, "unknown"); + yield* write(unknown, "000000.json", { seq: 0, action: { type: "NotARealAction" } }); + expect(yield* refuses(unknown)).toEqual(true); + }); + + it("a restart interleaved between the user event and its completion resolves once", function* () { + const root = yield* useTempDirectory("xmd-repl-interleave-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("pasted"); + + // The user event lands; the observer records acceptance; then the process + // restarts before the completion is appended. + yield* appendRecords(bag.path, [codexUser(message.text)]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("accepted"); + + const restarted = yield* useReplStore(bag.storeDir); + yield* reconcileRestart(restarted); + tally.replays += 1; + // Accepted work is not disturbed by restart, and is not re-attempted. + expect(messageState(restarted, bag.identity.id, message.id)).toEqual("accepted"); + const restartedPane = createFakePane(); + const again = yield* attemptStep( + restarted, + bag.identity.id, + restartedPane.probe, + bag.observer, + bag.options, + ); + expect(again.outcome).toEqual("skipped"); + expect(restartedPane.deliveries.length).toEqual(0); + + // The completion arrives after restart; it completes exactly once. + yield* appendRecords(bag.path, [codexComplete()]); + yield* observeStep(restarted, bag.identity.id, bag.observer); + expect(messageState(restarted, bag.identity.id, message.id)).toEqual("completed"); + const completions = role(restarted.state(), bag.identity.id).events.filter( + (event) => event.kind === "turn-completed", + ); + expect(completions.length).toEqual(1); + }); + + it("the report schema accepts a provider PASS and rejects an incomplete one", function* () { + const passProvider = (agent: string) => ({ + verdict: "PASS" as const, + versionKnown: true, + version: agent, + identityHash: identityHash(`${agent}-identity`), + sourceIdentityHash: identityHash(`${agent}-source`), + accepted: true, + completed: true, + }); + // A single-provider live journey attests its own provider; the gate for + // `live-claude` requires Claude's full evidence and a spent turn. + const claudePass: TerminalReplReport = { + schema: REPORT_SCHEMA, + verdict: "PASS", + mode: "live-claude", + runtime: "deno", + base: { sha: BASE_SHA }, + head: { sha: HEAD_SHA }, + providers: { + claude: passProvider("2.1.263"), + codex: { verdict: "n/a", versionKnown: false }, + }, + turnBudgets: { claudeAuthorized: 1, claudeSpent: 1, codexAuthorized: 0, codexSpent: 0 }, + matrix: [], + counters: tally, + deliveries, + restart, + cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + }; + expect((yield* validateReport(claudePass)).valid).toEqual(true); + + // Each of these is a Claude PASS missing a piece of the proof it requires. + const noHead = { ...claudePass }; + delete (noHead as { head?: unknown }).head; + expect((yield* validateReport(noHead)).valid).toEqual(false); + + const noClaudeTurn = { + ...claudePass, + turnBudgets: { ...claudePass.turnBudgets, claudeSpent: 0 }, + }; + expect((yield* validateReport(noClaudeTurn)).valid).toEqual(false); + + const claudeNotAccepted = { + ...claudePass, + providers: { + ...claudePass.providers, + claude: { ...passProvider("2.1.263"), accepted: false }, + }, + }; + expect((yield* validateReport(claudeNotAccepted)).valid).toEqual(false); + + const claudeNoVersion = { + ...claudePass, + providers: { + ...claudePass.providers, + claude: { + verdict: "PASS" as const, + versionKnown: true, + identityHash: identityHash("c-identity"), + sourceIdentityHash: identityHash("c-source"), + accepted: true, + completed: true, + }, + }, + }; + expect((yield* validateReport(claudeNoVersion)).valid).toEqual(false); + + const noDeliveries = { ...claudePass, deliveries: [] }; + expect((yield* validateReport(noDeliveries)).valid).toEqual(false); + + const forbiddenField = { ...claudePass, secret: "leak" }; + expect((yield* validateReport(forbiddenField)).valid).toEqual(false); + }); + it("produces a valid terminal-repl-poc-report.v1 with every RP row passing", function* () { const passed = matrix.filter((entry) => entry.result === "pass").length; expect(matrix.length).toEqual(18); expect(passed).toEqual(18); @@ -985,23 +1333,19 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(tally.busyAdmissions).toEqual(0); expect(tally.manualActivityAdmissions).toEqual(0); - const report = { + // The offline suite cannot spend a live turn, so its own report is the + // honest NOT_AUTHORIZED shape rather than an overall PASS — which the schema + // reserves for a run that also completed both authorized live journeys. + const offline: TerminalReplReport = { schema: REPORT_SCHEMA, - verdict: "PASS" as const, - mode: "deterministic" as const, + verdict: "NOT_AUTHORIZED", + mode: "deterministic", runtime: runtimeName(), + detail: "offline deterministic matrix; live journeys require authorization", base: { sha: BASE_SHA }, providers: { - claude: { - verdict: "PASS" as const, - versionKnown: false, - identityHash: identityHash("claude-deterministic"), - }, - codex: { - verdict: "PASS" as const, - versionKnown: false, - identityHash: identityHash("codex-deterministic"), - }, + claude: { verdict: "NOT_AUTHORIZED", versionKnown: false }, + codex: { verdict: "NOT_AUTHORIZED", versionKnown: false }, }, turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, matrix, @@ -1010,7 +1354,7 @@ describe("issue #774 — black-box REPL messaging POC", () => { restart, cleanup, }; - const validation = yield* validateReport(report); + const validation = yield* validateReport(offline); if (!validation.valid) { throw new Error(`report failed schema validation: ${validation.errors.join("; ")}`); } From 0105635a44c17c5ef9cf89cdc377526eb204ca3c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 19:51:59 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Fold=20provider=20growth=20i?= =?UTF-8?q?nto=20the=20guard=20and=20harden=20the=20REPL=20POC=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Architect round on PR #779. Six blockers: 1. Provider state (open turn, cursor, event count, physical size incl. a partial tail) is sampled across the barrier and again after the buffer is prepared, so a turn opening during buffer load or the barrier refuses with zero paste. 2. The live tmux boundary is one server-side if-shell recheck-and-paste over an injectable command seam with real control-mode activity generations; its outcome is acknowledged (pasted/declined/uncertain), never always-pasted. Boundary tests drive the live probe through a fake seam. 3. Observer locates by exact identity and project from bounded header reads, fails closed on missing project metadata, requires Claude's requestId turn identity, and derives PROVIDER_EXCLUDED from an explicit capability, not a deadline. 4. Delivery prepares before the final sample and cleans file and buffer on success, decline, uncertain and cancellation; the live child proves close after SIGKILL; the isolated root is created synchronously and cleanup is read only after teardown; provider state is cleaned via the provider's own op. 5. The store validates legal transitions on replay and dispatch, and observation deduplicates an event a restart already recorded. 6. An overall aggregator gates PASS on RP1-RP18 plus both live journeys, safe counters, no re-execution and verified cleanup; the schema rejects an unsafe, uncleaned or re-executed PASS; live reports carry a real head SHA and count the materialization turn; the documents invoke the full checked-in validator. No production, specification, dependency or lockfile path changed. --- .../poc/repl/ClaudeBlackBoxRepl.test.md | 42 +- .../poc/repl/CodexBlackBoxRepl.test.md | 42 +- .../terminal-tmux/poc/repl/claude-observer.ts | 63 +- .../terminal-tmux/poc/repl/codex-observer.ts | 1 + packages/terminal-tmux/poc/repl/controller.ts | 178 ++--- .../terminal-tmux/poc/repl/convergence.ts | 10 +- packages/terminal-tmux/poc/repl/delivery.ts | 88 ++- .../terminal-tmux/poc/repl/live-supervisor.ts | 15 +- .../terminal-tmux/poc/repl/live-worker.ts | 663 ++++++++++++------ packages/terminal-tmux/poc/repl/observer.ts | 69 +- .../terminal-tmux/poc/repl/report.schema.json | 113 ++- packages/terminal-tmux/poc/repl/report.ts | 142 +++- packages/terminal-tmux/poc/repl/store.ts | 89 +++ .../tests/fixtures/repl-poc/fake-terminal.ts | 6 +- packages/terminal-tmux/tests/repl-poc.test.ts | 454 +++++++++++- 15 files changed, 1562 insertions(+), 413 deletions(-) diff --git a/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md b/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md index e3ac5a0b2..8177fa009 100644 --- a/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md +++ b/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md @@ -44,16 +44,24 @@ tmux identifier, and no raw native identity. ```json { "type": "object", - "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], + "required": ["report", "schemaValid"], "properties": { - "schema": { "const": "terminal-repl-poc-report.v1" }, - "verdict": { - "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] + "report": { + "type": "object", + "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], + "properties": { + "schema": { "const": "terminal-repl-poc-report.v1" }, + "verdict": { + "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] + }, + "mode": { "type": "string" }, + "detail": { "type": "string" }, + "turnBudgets": { "type": "object" }, + "counters": { "type": "object" } + } }, - "mode": { "type": "string" }, - "detail": { "type": "string" }, - "turnBudgets": { "type": "object" }, - "counters": { "type": "object" } + "schemaValid": { "type": "boolean" }, + "errors": { "type": "array" } } } ``` @@ -76,21 +84,27 @@ answered. {run.stdout} +The supervisor validated the report against the checked-in +`report.schema.json` — the full validator — before printing it, so an invalid +report fails here rather than being read past. + + + The whole report is shown before anything is judged. ```json {run.stdout} ``` - + The exact marked message was accepted under the intended native identity and the turn completed, observed from Claude's own session file and never from the screen. - - - + + + @@ -98,12 +112,12 @@ turn completed, observed from Claude's own session file and never from the scree The ordinary path: without both gates nothing started and no turn was spent. This is the pass on a developer machine and in CI. - + - + diff --git a/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md b/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md index da06841c5..ecdf3d0f3 100644 --- a/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md +++ b/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md @@ -45,16 +45,24 @@ tmux identifier, and no raw native identity. ```json { "type": "object", - "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], + "required": ["report", "schemaValid"], "properties": { - "schema": { "const": "terminal-repl-poc-report.v1" }, - "verdict": { - "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] + "report": { + "type": "object", + "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], + "properties": { + "schema": { "const": "terminal-repl-poc-report.v1" }, + "verdict": { + "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] + }, + "mode": { "type": "string" }, + "detail": { "type": "string" }, + "turnBudgets": { "type": "object" }, + "counters": { "type": "object" } + } }, - "mode": { "type": "string" }, - "detail": { "type": "string" }, - "turnBudgets": { "type": "object" }, - "counters": { "type": "object" } + "schemaValid": { "type": "boolean" }, + "errors": { "type": "array" } } } ``` @@ -77,21 +85,27 @@ answered. {run.stdout} +The supervisor validated the report against the checked-in +`report.schema.json` — the full validator — before printing it, so an invalid +report fails here rather than being read past. + + + The whole report is shown before anything is judged. ```json {run.stdout} ``` - + The exact marked message was accepted under the intended native identity and the turn completed, observed from Codex's own rollout file and never from the screen. - - - + + + @@ -99,12 +113,12 @@ turn completed, observed from Codex's own rollout file and never from the screen The ordinary path: without both gates nothing started and no turn was spent. This is the pass on a developer machine and in CI. - + - + diff --git a/packages/terminal-tmux/poc/repl/claude-observer.ts b/packages/terminal-tmux/poc/repl/claude-observer.ts index 108e283f1..42c0cd1a7 100644 --- a/packages/terminal-tmux/poc/repl/claude-observer.ts +++ b/packages/terminal-tmux/poc/repl/claude-observer.ts @@ -18,27 +18,41 @@ import type { ParsedRecord, ProviderParser } from "./observer.ts"; +/** + * Build a Claude parser. + * + * `supportsCompletion` is the build's declared capability. When it is false, a + * `result` record is ignored rather than treated as a completion boundary, and + * the caller reports `PROVIDER_EXCLUDED` from that explicit fact — never from a + * deadline. The default is a build whose interactive format does carry the + * closing record. + */ +export function createClaudeParser(supportsCompletion: boolean): ProviderParser { + return { + provider: "claude", + supportsCompletion, + identityFromName(name) { + return name.endsWith(".jsonl") ? name.slice(0, -".jsonl".length) : undefined; + }, + classify(record) { + const type = record["type"]; + if (type === "user") { + return classifyMessage(record, "user-accepted"); + } + if (type === "assistant") { + return classifyMessage(record, "assistant-output"); + } + if (type === "result") { + return supportsCompletion ? classifyResult(record) : { kind: "ignore" }; + } + // Summaries, system notices and anything else bear on nothing here. + return { kind: "ignore" }; + }, + }; +} + /** The Claude parser: filename identity, `sessionId`-tagged, `requestId`-grouped. */ -export const claudeParser: ProviderParser = { - provider: "claude", - identityFromName(name) { - return name.endsWith(".jsonl") ? name.slice(0, -".jsonl".length) : undefined; - }, - classify(record) { - const type = record["type"]; - if (type === "user") { - return classifyMessage(record, "user-accepted"); - } - if (type === "assistant") { - return classifyMessage(record, "assistant-output"); - } - if (type === "result") { - return classifyResult(record); - } - // Summaries, system notices and anything else bear on nothing here. - return { kind: "ignore" }; - }, -}; +export const claudeParser: ProviderParser = createClaudeParser(true); /** The turn a record belongs to: its `requestId`, when it carries one. */ function turnOf(record: Record): string | undefined { @@ -60,6 +74,9 @@ function classifyMessage( return { kind: "unsupported", reason: `${kind} record with no readable text` }; } const turn = turnOf(record); + if (turn === undefined) { + return { kind: "unsupported", reason: `${kind} record with no requestId turn identity` }; + } if (kind === "user-accepted") { return { kind: "user-accepted", identity, text, turn }; } @@ -72,7 +89,11 @@ function classifyResult(record: Record): ParsedRecord { if (typeof identity !== "string" || identity.length === 0) { return { kind: "unsupported", reason: "result record with no sessionId" }; } - return { kind: "turn-completed", identity, turn: turnOf(record) }; + const turn = turnOf(record); + if (turn === undefined) { + return { kind: "unsupported", reason: "result record with no requestId turn identity" }; + } + return { kind: "turn-completed", identity, turn }; } /** Join the text parts of a Claude message, or nothing when there are none. */ diff --git a/packages/terminal-tmux/poc/repl/codex-observer.ts b/packages/terminal-tmux/poc/repl/codex-observer.ts index 87b3f50c6..740703c93 100644 --- a/packages/terminal-tmux/poc/repl/codex-observer.ts +++ b/packages/terminal-tmux/poc/repl/codex-observer.ts @@ -20,6 +20,7 @@ import type { ParsedRecord, ProviderParser } from "./observer.ts"; /** The Codex parser: `session_meta` identity and project, `event_msg` events. */ export const codexParser: ProviderParser = { provider: "codex", + supportsCompletion: true, identityFromName() { // Codex names its rollout files by timestamp, not by identity, so the // identity is only ever read from the `session_meta` record inside. diff --git a/packages/terminal-tmux/poc/repl/controller.ts b/packages/terminal-tmux/poc/repl/controller.ts index 327f54fa3..a0e8d8b04 100644 --- a/packages/terminal-tmux/poc/repl/controller.ts +++ b/packages/terminal-tmux/poc/repl/controller.ts @@ -7,15 +7,18 @@ * - `attemptStep` takes one role's queue head from `queued` to a pasted attempt. * It composes convergence's combined sample from the pane probe and the * provider observer, so a turn that opens during the acknowledged barrier fails - * convergence exactly as a pane change does. It records `AttemptStarted` - * durably, re-samples once more between convergence and the guard, and only then - * pastes under the final guard. A pane that is unavailable, replaced, busy or - * moving keeps the message queued; an observer that cannot map the source - * refuses; a paste whose whole delivery could not be proved becomes uncertain - * and is never retried. + * convergence exactly as a pane change does. It then prepares the private + * message file and tmux buffer, takes one final combined sample *after* that + * preparation — catching a turn that opened or a record that grew while the + * buffer loaded, including a partial tail — records `AttemptStarted` durably, + * and only then enters the single guarded paste. A pane that is unavailable, + * replaced, busy or moving keeps the message queued; an observer that cannot + * map the source refuses; a paste whose whole delivery could not be proved + * becomes uncertain and is never retried. * - `observeStep` reads the provider session file forward from the durable * cursor and turns exact matching records into acceptance and completion, - * grouped by the provider's own turn identity. A refusal advances no cursor. + * grouped by the provider's own turn identity, deduplicating an event a restart + * already recorded. A refusal advances no cursor. * - `reconcileRestart` turns any attempt a restart left in flight into an * `uncertain` outcome that is never pasted again. * @@ -26,8 +29,8 @@ import type { Operation } from "effection"; import { converge, providerUnchanged, structurallyEqual } from "./convergence.ts"; import type { PaneProbe, SampleResult } from "./convergence.ts"; -import { deliver } from "./delivery.ts"; -import { hasOpenTurn, locate, read } from "./observer.ts"; +import { withPreparedDelivery } from "./delivery.ts"; +import { hasOpenTurn, locate, physicalSizeOf, read } from "./observer.ts"; import type { ObservationRefusal, ProviderParser } from "./observer.ts"; import { queueHead } from "./state.ts"; import type { NormalizedEvent, RoleState } from "./state.ts"; @@ -104,13 +107,14 @@ function turnMatches(left: string | undefined, right: string | undefined): boole return left === right; } -/** How the provider file reads now: idle/open, its length, its event count. */ +/** How the provider file reads now: idle/open, its cursor, events, physical size. */ type ProviderReadResult = | { readonly outcome: "read"; readonly openTurn: boolean; readonly cursor: number; readonly eventCount: number; + readonly physicalSize: number; } | { readonly outcome: "refused"; readonly refusal: ObservationRefusal }; @@ -125,11 +129,15 @@ function readProvider(observer: ObserverSource, identity: string): Operation probe.barrier()); if (converged.outcome === "not-ready") { - // An observer that could not map the source is a refusal, distinct from a - // pane that was merely busy or moving. const refusal = asRefusal(converged.reason); - if (refusal !== undefined) { - // Reset the queue head from `converging` before reporting the refusal. - yield* store.dispatch({ - type: "ConvergenceInvalidated", - key, - id: head.id, - reason: converged.reason, - }); - yield* store.dispatch({ type: "ObserverRefused", key, reason: refusal }); - return { outcome: "refused", refusal }; - } yield* store.dispatch({ type: "ConvergenceInvalidated", key, id: head.id, reason: converged.reason, }); + if (refusal !== undefined) { + yield* store.dispatch({ type: "ObserverRefused", key, reason: refusal }); + return { outcome: "refused", refusal }; + } if (converged.reason === "provider-open-turn") { yield* store.dispatch({ type: "ProviderBusy", key }); } @@ -236,60 +237,68 @@ export function attemptStep( } yield* store.dispatch({ type: "TerminalObserved", key, readiness: "ready" }); - // The durable intent, before any byte reaches the terminal. - yield* store.dispatch({ type: "AttemptStarted", key, id: head.id }); - - // One more combined read between convergence and the guard. A turn that - // opened, a record that appeared, an identity that changed, or a pane that - // moved in this window declines the attempt with nothing sent. - const recheck = yield* sampler(); - if ( - recheck.outcome === "unreadable" || - recheck.sample.provider.openTurn || - !providerUnchanged(recheck.sample.provider, converged.guard.provider) || - !structurallyEqual(recheck.sample.pane, converged.guard.pane) || - recheck.sample.pane.epoch !== converged.guard.pane.epoch - ) { - const reason = - recheck.outcome === "unreadable" ? recheck.reason : "provider-or-pane-changed-before-guard"; - yield* store.dispatch({ type: "AttemptDeclined", key, id: head.id, reason }); - return { outcome: "declined", id: head.id, reason }; - } - const delivered = yield* deliver(probe, { - dir: options.messageDir, - id: head.id, - bytes: head.text, - bracketedPaste: options.bracketedPaste, - submitKey: options.submitKey, - guard: converged.guard.pane, - }); - if (delivered.outcome === "declined") { - yield* store.dispatch({ - type: "AttemptDeclined", - key, + // Prepare the private file and the buffer first, then take the final sample. + // A turn opening or a record growing while the buffer loads is caught here, + // before any intent is recorded or any byte is sent. + return yield* withPreparedDelivery( + probe, + { + dir: options.messageDir, id: head.id, - reason: delivered.reason, - }); - return { outcome: "declined", id: head.id, reason: delivered.reason }; - } - if (delivered.outcome === "uncertain") { - // Bytes may have reached the terminal but the whole delivery is unproved: - // uncertain, and never pasted again. - yield* store.dispatch({ - type: "AttemptUncertain", - key, - id: head.id, - reason: delivered.reason, - }); - return { outcome: "uncertain", id: head.id, reason: delivered.reason }; - } - return { - outcome: "pasted", - id: head.id, - byteCount: delivered.byteCount, - hash: delivered.hash, - }; + bytes: head.text, + bracketedPaste: options.bracketedPaste, + submitKey: options.submitKey, + }, + function* (prepared): Operation { + const recheck = yield* sampler(); + if ( + recheck.outcome === "unreadable" || + recheck.sample.provider.openTurn || + !providerUnchanged(recheck.sample.provider, converged.guard.provider) || + !structurallyEqual(recheck.sample.pane, converged.guard.pane) || + recheck.sample.pane.epoch !== converged.guard.pane.epoch + ) { + const reason = + recheck.outcome === "unreadable" + ? recheck.reason + : "provider-or-pane-changed-before-guard"; + // Nothing was recorded and nothing sent: reset the head to queued. + yield* store.dispatch({ type: "ConvergenceInvalidated", key, id: head.id, reason }); + return { outcome: "declined", id: head.id, reason }; + } + + // The durable intent, before the single guarded paste. + yield* store.dispatch({ type: "AttemptStarted", key, id: head.id }); + const delivered = yield* prepared.paste(converged.guard.pane); + if (delivered.outcome === "declined") { + yield* store.dispatch({ + type: "AttemptDeclined", + key, + id: head.id, + reason: delivered.reason, + }); + return { outcome: "declined", id: head.id, reason: delivered.reason }; + } + if (delivered.outcome === "uncertain") { + // Bytes may have reached the terminal but the whole delivery is + // unproved: uncertain, and never pasted again. + yield* store.dispatch({ + type: "AttemptUncertain", + key, + id: head.id, + reason: delivered.reason, + }); + return { outcome: "uncertain", id: head.id, reason: delivered.reason }; + } + return { + outcome: "pasted", + id: head.id, + byteCount: delivered.byteCount, + hash: delivered.hash, + }; + }, + ); })(); } @@ -304,8 +313,9 @@ function asRefusal(reason: string): ObservationRefusal | undefined { * * A located file is read from the durable cursor. Exact matching user records * settle an attempt as accepted; assistant output and the completion boundary - * that follow — grouped by the same provider turn — settle it as completed. A - * refusal advances no cursor and leaves every message where it was. + * that follow — grouped by the same provider turn — settle it as completed. An + * event a restart already recorded is not dispatched again. A refusal advances no + * cursor and leaves every message where it was. */ export function observeStep( store: ReplStore, @@ -358,6 +368,12 @@ function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Oper if (role === undefined) { return; } + // An event a restart already recorded is not dispatched again: the durable + // event carries its own file-and-byte-range key, so re-reading the same + // record after an interruption produces no duplicate action or event. + if (role.events.some((existing) => existing.key === event.key)) { + return; + } if (event.kind === "user-accepted") { // The exact attempted bytes, under the intended identity. A user record // whose text differs is someone else's turn and settles nothing. diff --git a/packages/terminal-tmux/poc/repl/convergence.ts b/packages/terminal-tmux/poc/repl/convergence.ts index 04b51c1e8..be20ad83f 100644 --- a/packages/terminal-tmux/poc/repl/convergence.ts +++ b/packages/terminal-tmux/poc/repl/convergence.ts @@ -55,6 +55,13 @@ export interface ProviderSample { readonly cursor: number; /** The number of relevant records observed, so a new turn is a new event. */ readonly eventCount: number; + /** + * The file's physical byte length, including a partial tail no cursor covers. + * A record being written — even one still missing its newline — grows this, + * so a turn opening during the barrier or during buffer preparation is seen + * even before it parses as a complete event. + */ + readonly physicalSize: number; } /** One combined sample of the pane and the provider it hosts. */ @@ -186,7 +193,8 @@ export function providerUnchanged(left: ProviderSample, right: ProviderSample): return ( left.cursor === right.cursor && left.eventCount === right.eventCount && - left.openTurn === right.openTurn + left.openTurn === right.openTurn && + left.physicalSize === right.physicalSize ); } diff --git a/packages/terminal-tmux/poc/repl/delivery.ts b/packages/terminal-tmux/poc/repl/delivery.ts index 711e463c6..4c6289b91 100644 --- a/packages/terminal-tmux/poc/repl/delivery.ts +++ b/packages/terminal-tmux/poc/repl/delivery.ts @@ -7,16 +7,19 @@ * keystroke, so linefeeds in the message stay in the message rather than * submitting it. Bracketed paste is used where the terminal supports it. * - * Every native resource this acquires has its cleanup registered *before* the - * acquisition, so a halt between acquiring and registering cannot strand it: the - * private file and the tmux buffer are both scheduled for removal before either - * is created. They are removed however the attempt settles. + * Preparation and the paste are separate phases on purpose. `withPreparedDelivery` + * writes the private file and loads the buffer — registering their cleanup before + * either exists, so a halt between acquiring and registering cannot strand them — + * and then hands the caller a `PreparedDelivery`. The caller takes its final + * provider/pane sample *after* preparation, so a turn that opens or a record that + * grows while the buffer is loading is caught before anything is sent. Only then + * does the caller record `AttemptStarted` and call `paste`. * - * The paste itself is the final guarded operation: it rechecks the pane against - * the guard convergence produced and either declines or pastes, with no - * suspension in between. An unproved outcome is never a silent success — a - * decline sent nothing and keeps the message queued, while an uncertain outcome - * means bytes may have gone and the message becomes uncertain, never retried. + * The paste itself is the final guarded operation: one server-side conditional + * that rechecks the pane and either pastes, declines, or reports its own outcome + * unreadable, with no suspension between the recheck and the paste. A decline + * sent nothing and keeps the message queued; an uncertain outcome means bytes may + * have gone and the message becomes uncertain, never retried. */ import { ensure, scoped, until } from "effection"; @@ -25,9 +28,9 @@ import { rm, writeTextFile } from "@effectionx/fs"; import { chmod } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; -import type { GuardOutcome, PaneProbe, PaneSnapshot } from "./convergence.ts"; +import type { PaneProbe, PaneSnapshot } from "./convergence.ts"; -/** Everything one delivery attempt needs. */ +/** What one delivery needs to prepare and paste. */ export interface DeliveryRequest { /** The private mode-`0700` directory the message file is written under. */ readonly dir: string; @@ -39,8 +42,6 @@ export interface DeliveryRequest { readonly bracketedPaste: boolean; /** The key that submits the pasted message, sent on its own. */ readonly submitKey: string; - /** The converged snapshot the final guard rechecks against. */ - readonly guard: PaneSnapshot; } /** What a delivery attempt established, with report-safe evidence. */ @@ -55,20 +56,34 @@ export type DeliveryOutcome = | { readonly outcome: "declined"; readonly reason: string } | { readonly outcome: "uncertain"; readonly reason: string }; +/** A prepared delivery: the buffer is loaded, awaiting the guarded paste. */ +export interface PreparedDelivery { + /** The exact byte count that will be delivered. */ + readonly byteCount: number; + /** A hash of the bytes; the bytes themselves never leave delivery. */ + readonly hash: string; + /** Recheck the pane against `guard` and paste, decline, or report uncertain. */ + paste(guard: PaneSnapshot): Operation; +} + /** A uniquely named tmux buffer for one message. */ export function bufferName(id: string): string { return `xmd-repl-${id}`; } /** - * Deliver one message's literal bytes to a pane, under the final guard. + * Prepare one message's private file and tmux buffer, then run `body`. * - * The private file and the tmux buffer are this scope's: both are removed when - * the attempt settles, whether the guard pasted, declined, or left the outcome - * uncertain, and whichever cleanup order the scope unwinds in. + * The file and the buffer are this scope's: both are removed when the scope + * settles, whether `body` pasted, declined, left the outcome uncertain, or was + * cancelled mid-flight. Cleanup is registered before either resource exists. */ -export function deliver(probe: PaneProbe, request: DeliveryRequest): Operation { - return scoped(function* (): Operation { +export function withPreparedDelivery( + probe: PaneProbe, + request: DeliveryRequest, + body: (prepared: PreparedDelivery) => Operation, +): Operation { + return scoped(function* (): Operation { const path = join(request.dir, `${request.id}.msg`); const buffer = bufferName(request.id); // Cleanup registered before either resource exists, so a halt between @@ -80,22 +95,27 @@ export function deliver(probe: PaneProbe, request: DeliveryRequest): Operation { + const guarded = yield* probe.guardedPaste(guard, { + buffer, + bracketedPaste: request.bracketedPaste, + submitKey: request.submitKey, + }); + if (guarded.outcome === "declined") { + return { outcome: "declined", reason: guarded.reason }; + } + if (guarded.outcome === "uncertain") { + return { outcome: "uncertain", reason: guarded.reason }; + } + return { outcome: "pasted", byteCount, hash }; + }, }; + return yield* body(prepared); }); } diff --git a/packages/terminal-tmux/poc/repl/live-supervisor.ts b/packages/terminal-tmux/poc/repl/live-supervisor.ts index d6c0f2167..1e35c3aae 100644 --- a/packages/terminal-tmux/poc/repl/live-supervisor.ts +++ b/packages/terminal-tmux/poc/repl/live-supervisor.ts @@ -20,7 +20,7 @@ import { main } from "effection"; import type { Operation } from "effection"; import process from "node:process"; -import { notAuthorizedReport } from "./report.ts"; +import { notAuthorizedReport, validateReport } from "./report.ts"; import type { ReportMode, TerminalReplReport } from "./report.ts"; import { runLiveJourney } from "./live-worker.ts"; @@ -128,6 +128,17 @@ if (import.meta.main) { return; } const report = yield* runLiveProof(provider, process.env, BASE_SHA); - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + // The report is validated against the checked-in schema — the full validator, + // not a weaker inline subset — before it is printed. The document proof reads + // both the report and this verdict, so a report that failed the schema fails + // the document. + const validation = yield* validateReport(report); + const schemaValid = validation.valid; + process.stdout.write( + `${JSON.stringify({ report, schemaValid, errors: validation.valid ? [] : validation.errors }, null, 2)}\n`, + ); + if (!schemaValid) { + process.exitCode = 1; + } }); } diff --git a/packages/terminal-tmux/poc/repl/live-worker.ts b/packages/terminal-tmux/poc/repl/live-worker.ts index 45c6713f8..6ac90dc9b 100644 --- a/packages/terminal-tmux/poc/repl/live-worker.ts +++ b/packages/terminal-tmux/poc/repl/live-worker.ts @@ -36,11 +36,13 @@ * swept — the same discipline the repository's own real-agent proofs use. */ -import { ensure, race, resource, sleep, until, withResolvers } from "effection"; +import { ensure, race, resource, scoped, sleep, spawn, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { exec } from "@effectionx/process"; +import { lines } from "@effectionx/stream-helpers"; import { ensureDir, exists, readTextFile, readdir, rm } from "@effectionx/fs"; -import { chmod, copyFile, mkdtemp, realpath } from "node:fs/promises"; +import { chmod, copyFile, realpath } from "node:fs/promises"; +import { mkdtempSync } from "node:fs"; import { spawn as spawnChild } from "node:child_process"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -48,23 +50,26 @@ import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import process from "node:process"; import type { GuardOutcome, PaneProbe, PaneSnapshot, PasteRequest } from "./convergence.ts"; -import { structurallyEqual } from "./convergence.ts"; -import { attemptStep, observeStep, settleUnconfirmed } from "./controller.ts"; +import { attemptStep, observeStep, reconcileRestart, settleUnconfirmed } from "./controller.ts"; import type { DeliveryOptions, ObserverSource } from "./controller.ts"; import { useReplStore } from "./store.ts"; import type { ReplStore } from "./store.ts"; import { claudeParser } from "./claude-observer.ts"; +import { locate } from "./observer.ts"; import { codexParser } from "./codex-observer.ts"; import type { LiveProvider } from "./live-supervisor.ts"; import type { + CleanupEvidence, DeliveryEvidence, ProviderReport, ProviderVerdict, ReportCounters, ReportMode, + ReportVerdict, + RestartEvidence, TerminalReplReport, } from "./report.ts"; -import { identityHash, zeroCounters } from "./report.ts"; +import { countersSafe, decideProviderVerdict, identityHash, zeroCounters } from "./report.ts"; /** The repository root, four levels up from this module. */ const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); @@ -101,9 +106,13 @@ interface Roots { export function useIsolatedRoots(): Operation { return resource(function* (provide) { const operatorHome = process.env.HOME ?? homedir(); - const root = yield* until(mkdtemp(join(tmpdir(), "xmd-repl-live-"))); - yield* until(chmod(root, 0o700)); + // Created synchronously so nothing can suspend between naming the root and + // registering its removal — an asynchronous create halted mid-flight would + // leave a directory nothing owns. + // oxlint-disable-next-line local/no-sync-filesystem + const root = mkdtempSync(join(tmpdir(), "xmd-repl-live-")); yield* ensure(() => rm(root, { recursive: true, force: true })); + yield* until(chmod(root, 0o700)); const home = join(root, "home"); const tmp = join(root, "tmp"); @@ -188,54 +197,62 @@ function tmuxExec( })(); } -/** The trimmed stdout of one tmux command, or "" when it failed. */ -function tmuxRun( - socket: string, - args: readonly string[], - env: Record, -): Operation { - return (function* (): Operation { - const result = yield* tmuxExec(socket, args, env); - return result.code === 0 ? result.stdout : ""; - })(); +/** + * One tmux command run against the private server, and its acknowledged result. + * + * The single seam the live pane probe is built on. The journey supplies the real + * one (an `exec` of `tmux`); a test supplies a fake that returns canned command + * outputs, so the probe's guard contract is exercised without a real server and + * the fake and the live boundary enforce exactly the same contract. + */ +export type TmuxCommand = (args: readonly string[]) => Operation<{ code: number; stdout: string }>; + +/** + * The pane's real output and visible-client activity generations. + * + * Counted from the server's own control-mode events — `%output` and the + * `%client-*` family — not from `history_size` or a timestamp. A change in either + * generation between convergence and the final combined sample invalidates the + * attempt. + */ +export interface PaneActivity { + read(): Operation<{ outputEvents: number; clientActivity: number }>; } /** - * A real `PaneProbe` over the grid's private tmux server. + * A `PaneProbe` over an injectable tmux command seam and activity source. * - * Snapshots come from tmux format variables — never from screen text. The - * guarded paste is one server-side `if-shell -F` that rechecks the pane's - * process and pastes the pre-loaded buffer with a separate submit key, so the - * recheck and the paste share one command queue. + * `guardedPaste` is one server-side `if-shell` conditional: it rechecks the pane + * generation (`pane_id`), process, liveness and mode, then pastes the pre-loaded + * buffer, sends the submit key, and prints an acknowledgement — or takes the + * decline branch — in a single command with no suspension between the recheck and + * the paste. Its outcome is read from the acknowledgement: the marker means + * pasted, the decline marker means declined, a failed command or a missing + * acknowledgement means uncertain — never pasted-as-proved. The message bytes + * stay in the buffer and never enter the command string. */ -export function tmuxPaneProbe( - socket: string, - target: string, - env: Record, -): PaneProbe { +export function paneProbeOver(run: TmuxCommand, target: string, activity: PaneActivity): PaneProbe { function readSnapshot(): Operation { return (function* (): Operation { const format = - "#{pane_id}|#{pane_pid}|#{pane_tty}|#{pane_dead}|#{pane_in_mode}|#{pane_current_command}|" + - "#{history_size}|#{session_activity}|#{window_activity}"; - const line = yield* tmuxRun(socket, ["display", "-p", "-t", target, format], env); - const [paneId, pid, tty, dead, mode, command, history, sessionActivity, windowActivity] = - line.split("|"); + "#{pane_id}|#{pane_pid}|#{pane_tty}|#{pane_dead}|#{pane_in_mode}|#{pane_current_command}"; + const shown = yield* run(["display", "-p", "-t", target, format]); + const [paneId, pid, tty, dead, mode, command] = (shown.code === 0 ? shown.stdout : "").split( + "|", + ); const alive = dead === "0" && (pid ?? "").length > 0; - const outputEvents = Number(history ?? "0"); - const clientActivity = Number(sessionActivity ?? "0") + Number(windowActivity ?? "0"); + const generations = yield* activity.read(); return { - // `%N` is stable for one pane and changes when a pane is replaced, so it - // is the generation an old attempt must not be adopted across. + // `%N` is stable for one pane and changes when a pane is replaced. generation: Number((paneId ?? "").replace(/^%/, "")), pid: alive ? Number(pid) : -1, terminal: alive ? (tty ?? "") : "", alive, mode: mode === "1" ? "copy" : "", foregroundProcess: commandHash(command ?? ""), - clientActivity, - outputEvents, - epoch: outputEvents + clientActivity, + clientActivity: generations.clientActivity, + outputEvents: generations.outputEvents, + epoch: generations.outputEvents + generations.clientActivity, }; })(); } @@ -243,51 +260,89 @@ export function tmuxPaneProbe( return { snapshot: readSnapshot, *barrier(): Operation { - // An acknowledged round-trip: displaying a constant waits for the server - // to answer without changing anything. - yield* tmuxRun(socket, ["display", "-p", "-t", target, "barrier"], env); + // An acknowledged round-trip that changes nothing by itself. + yield* run(["display", "-p", "-t", target, "barrier"]); }, *loadBuffer(buffer, path): Operation { - yield* tmuxRun(socket, ["load-buffer", "-b", buffer, path], env); + yield* run(["load-buffer", "-b", buffer, path]); }, *deleteBuffer(buffer): Operation { - // Best effort: a buffer that was never loaded is nothing to remove. - yield* tmuxExec(socket, ["delete-buffer", "-b", buffer], env); + yield* run(["delete-buffer", "-b", buffer]); }, *guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation { - // Recheck every required final fact against the guard — generation (pane - // id), process, terminal, mode, and the output/client-activity epoch — not - // the PID alone. A change since convergence declines with nothing sent. - const current = yield* readSnapshot(); - if (!structurallyEqual(guard, current) || guard.epoch !== current.epoch) { - return { outcome: "declined", reason: "guard-changed" }; + const nonce = `XR${Math.random().toString(36).slice(2, 10)}`; + const condition = + `#{&&:#{==:#{pane_id},%${guard.generation}},` + + `#{&&:#{==:#{pane_pid},${guard.pid}},` + + `#{&&:#{==:#{pane_dead},0},#{==:#{pane_in_mode},0}}}}`; + const bracket = delivery.bracketedPaste ? " -p" : ""; + const pasteAndSubmit = + `paste-buffer -b ${delivery.buffer} -t ${target}${bracket} ; ` + + `send-keys -t ${target} ${delivery.submitKey} ; display -p ${nonce}`; + const result = yield* run([ + "if-shell", + "-F", + condition, + pasteAndSubmit, + "display -p DECLINED", + ]); + if (result.code !== 0) { + return { outcome: "uncertain", reason: "guard-command-failed" }; } - if (!current.alive) { - return { outcome: "declined", reason: "pane-unavailable" }; + if (result.stdout.includes(nonce)) { + return { outcome: "pasted" }; } - // The paste and the submit are two server commands whose own outcomes are - // read: a failed paste declined nothing to the pane, while a paste that - // succeeded before a failed submit may have left bytes in the composer and - // is reported uncertain rather than pasted. The bytes never reach a shell - // or an argv — they come from the loaded buffer. - const bracket = delivery.bracketedPaste ? ["-p"] : []; - const paste = yield* tmuxExec( - socket, - ["paste-buffer", "-b", delivery.buffer, "-t", target, ...bracket], - env, - ); - if (paste.code !== 0) { - return { outcome: "declined", reason: "paste-command-failed" }; - } - const submit = yield* tmuxExec(socket, ["send-keys", "-t", target, delivery.submitKey], env); - if (submit.code !== 0) { - return { outcome: "uncertain", reason: "submit-unacknowledged" }; + if (result.stdout.includes("DECLINED")) { + return { outcome: "declined", reason: "guard-rejected" }; } - return { outcome: "pasted" }; + // The command ran but acknowledged neither branch: the paste may or may not + // have reached the pane, so the outcome is uncertain rather than pasted. + return { outcome: "uncertain", reason: "submit-unacknowledged" }; }, }; } +/** + * A live activity source backed by the server's control-mode event stream. + * + * It attaches one no-output control client and counts the `%output` and + * `%client-*` events the server reports, so the probe reads real output and + * visible-client generations rather than `history_size` or a timestamp. The + * client is this scope's and is torn down with it. + */ +export function useControlFeed( + socket: string, + env: Record, +): Operation { + return resource(function* (provide) { + let outputEvents = 0; + let clientActivity = 0; + yield* spawn(function* () { + const client = yield* exec("tmux", { + arguments: ["-S", socket, "-f", "/dev/null", "-C", "attach", "-f", "no-output"], + env, + }); + const reported = yield* lines()(client.stdout); + let next = yield* reported.next(); + while (!next.done) { + const line = next.value; + if (line.startsWith("%output")) { + outputEvents += 1; + } else if (line.startsWith("%client-")) { + clientActivity += 1; + } + next = yield* reported.next(); + } + }); + yield* provide({ + // deno-lint-ignore require-yield + *read(): Operation<{ outputEvents: number; clientActivity: number }> { + return { outputEvents, clientActivity }; + }, + }); + }); +} + /** A stable number for a pane's foreground command, distinguishing child from shell. */ function commandHash(command: string): number { let hash = 0; @@ -407,162 +462,307 @@ export function runLiveJourney( _env: Record, base: string, ): Operation { - return resource(function* (provide) { + return (function* (): Operation { const mode: ReportMode = provider === "claude" ? "live-claude" : "live-codex"; if (!(yield* exists(XMD_BINARY))) { - yield* provide(harnessFailed(mode, base, "dist/xmd is not built; run deno task build")); - return; + return harnessFailed(mode, base, "dist/xmd is not built; run deno task build"); } - const roots = yield* useIsolatedRoots(); - const store = yield* useReplStore(roots.storeDir); + const head = yield* currentHead(base); + + // Evidence the journey fills in; cleanup is read only after the scope below + // has torn down, so a cleanup field is never true before teardown settled. const counters: { -readonly [K in keyof ReportCounters]: ReportCounters[K] } = zeroCounters(); const deliveries: DeliveryEvidence[] = []; - - // Launch the grid document under a private pseudo-terminal. Its streams are - // ignored: the grid draws on its own pty, and the POC reads provider files - // and delivers through tmux, never through this child's stdio. - // Cleanup is registered before the child exists, so a halt during spawn - // cannot strand it. The `close` handler is named and removed in the same - // teardown that waits on it — kept through the wait, taken off in a - // synchronous finally — and a child that will not close is a teardown - // failure rather than a proved success. - const closed = withResolvers(); - let didClose = false; - let child: ReturnType | undefined; - const onClose = (): void => { - didClose = true; - closed.resolve(); + const restart = { + queuedRestored: 0, + uncertainAfterRestart: 0, + completedRestored: 0, + reExecutions: 0, }; - yield* ensure(function* () { - const running = child; - if (running === undefined) { - return; - } - try { - if (!didClose && running.pid !== undefined) { - running.kill("SIGINT"); - yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); - if (!didClose && running.pid !== undefined) { - running.kill("SIGKILL"); - yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); + const outcome = { + ran: false, + accepted: false, + completed: false, + identity: "", + sourceHash: "", + version: "", + supportsCompletion: provider === "claude" ? claudeParser.supportsCompletion : true, + materializationTurns: 0, + nativeTurns: 0, + harnessDetail: "", + }; + let providerSessionRemoved = false; + let rootPath = ""; + + try { + yield* scoped(function* (): Operation { + const roots = yield* useIsolatedRoots(); + rootPath = roots.root; + const store = yield* useReplStore(roots.storeDir); + + const closed = withResolvers(); + let didClose = false; + let child: ReturnType | undefined; + const onClose = (): void => { + didClose = true; + closed.resolve(); + }; + // Cleanup registered before the child exists; SIGKILL is followed by a + // mandatory close proof — a child that will not close is a teardown + // failure, never a proved success. + yield* ensure(function* () { + const running = child; + if (running === undefined) { + return; + } + try { + if (!didClose && running.pid !== undefined) { + running.kill("SIGINT"); + yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); + if (!didClose && running.pid !== undefined) { + running.kill("SIGKILL"); + yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); + } + } + if (!didClose) { + throw new Error("the launched grid child could not be proved closed at teardown"); + } + } finally { + running.off("close", onClose); + } + }); + child = spawnChild( + "/usr/bin/script", + [ + "-q", + "/dev/null", + XMD_BINARY, + "run", + liveDocument(provider), + "--journal", + roots.journal, + "--raw", + ], + { cwd: roots.project, env: roots.env, stdio: "ignore" }, + ); + child.on("close", onClose); + + const socket = yield* waitFor(SOCKET_DEADLINE_MS, () => discoverGridSocket(roots.tmp)); + if (socket === undefined) { + outcome.harnessDetail = "the grid's tmux socket never appeared under the owned TMPDIR"; + return; + } + const identity = yield* waitFor(IDENTITY_DEADLINE_MS, function* () { + const identities = yield* readLaunchIdentities(roots.journal); + return [...identities.values()].find((entry) => entry.provider === provider); + }); + if (identity === undefined) { + outcome.harnessDetail = + "the launch journal never retained this provider's native identity"; + return; + } + outcome.ran = true; + outcome.identity = identity.id; + + // Clean the exact provider session through the provider's own supported + // operation, and only that — never a direct transcript delete or a sweep. + yield* ensure(function* () { + const removal = + provider === "claude" + ? yield* runCommand("claude", ["project", "purge", "--yes", roots.project], roots.env) + : yield* runCommand("codex", ["delete", "--force", identity.id], roots.env); + providerSessionRemoved = removal; + }); + + const key = identity.id; + const target = "xmd:0.0"; + const run: TmuxCommand = (args) => tmuxExec(socket, args, roots.env); + const feed = yield* useControlFeed(socket, roots.env); + const probe = paneProbeOver(run, target, feed); + const observer: ObserverSource = providerObserver(provider, roots); + outcome.supportsCompletion = observer.parser.supportsCompletion; + const options: DeliveryOptions = { + messageDir: roots.messageDir, + bracketedPaste: true, + submitKey: "Enter", + }; + const first = yield* probe.snapshot(); + + yield* store.dispatch({ type: "ReplOpened", replSession: `live-${provider}` }); + yield* store.dispatch({ + type: "RoleBound", + key, + role: provider === "claude" ? "Implementor" : "Reviewer", + issue: "#774", + identity, + paneGeneration: first.generation, + }); + const marker = `MK-${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + const messageId = `msg-${randomUUID().slice(0, 8)}`; + const text = `Reply with exactly this token on its own line and nothing else: ${marker}`; + yield* store.dispatch({ type: "MessageQueued", key, id: messageId, text, marker }); + + const admitted = yield* driveUntilPasted( + store, + key, + probe, + observer, + options, + counters, + deliveries, + ); + if (admitted) { + outcome.nativeTurns = 1; + const deadline = Date.now() + ACCEPT_DEADLINE_MS; + while (Date.now() < deadline) { + yield* observeStep(store, key, observer); + const role = store.state().roles[key]; + const message = role?.messages.find((entry) => entry.id === messageId); + outcome.accepted = message?.state === "accepted" || message?.state === "completed"; + outcome.completed = message?.state === "completed"; + if (outcome.completed) { + break; + } + yield* sleep(POLL_MS); + } + if (!outcome.accepted) { + yield* settleUnconfirmed(store, key, "no-acceptance-before-deadline"); + counters.uncertain += 1; } } - } finally { - running.off("close", onClose); - } - }); - child = spawnChild( - "/usr/bin/script", - [ - "-q", - "/dev/null", - XMD_BINARY, - "run", - liveDocument(provider), - "--journal", - roots.journal, - "--raw", - ], - { cwd: roots.project, env: roots.env, stdio: "ignore" }, - ); - child.on("close", onClose); - const socket = yield* waitFor(SOCKET_DEADLINE_MS, () => discoverGridSocket(roots.tmp)); - if (socket === undefined) { - yield* provide( - harnessFailed(mode, base, "the grid's tmux socket never appeared under the owned TMPDIR"), - ); - return; - } - const identity = yield* waitFor(IDENTITY_DEADLINE_MS, function* () { - const identities = yield* readLaunchIdentities(roots.journal); - return [...identities.values()].find((entry) => entry.provider === provider); - }); - if (identity === undefined) { - yield* provide( - harnessFailed( - mode, - base, - "the launch journal never retained this provider's native identity", - ), - ); - return; + // The exact located source's file identity, hashed for the report. + const located = yield* locate(observer.parser, observer.directory, key, observer.project); + if (located.outcome === "located") { + outcome.sourceHash = identityHash(located.source.fileKey); + } + // The materialization turn XMD spent, counted from the journal, not guessed. + outcome.materializationTurns = yield* materializationTurns(roots.journal, key); + outcome.version = yield* providerVersion(provider, roots.env); + + // A live restart proof: a fresh store over the same log must restore the + // outcome and re-execute nothing. + const restarted = yield* useReplStore(roots.storeDir); + const settled = yield* reconcileRestart(restarted); + restart.uncertainAfterRestart = settled; + const restoredMessage = restarted + .state() + .roles[key]?.messages.find((m) => m.id === messageId); + if (restoredMessage?.state === "completed") { + restart.completedRestored = 1; + } else if (restoredMessage?.state === "queued") { + restart.queuedRestored = 1; + } + const replay = yield* attemptStep(restarted, key, probe, observer, options); + if (replay.outcome === "pasted") { + // Re-execution after restart is a defect the report must surface. + restart.reExecutions = 1; + } + }); + } catch (error) { + outcome.harnessDetail = classifyError(error); } - const key = identity.id; - // A provider-specific document launches exactly one pane, so the target is - // always pane 0 of the grid. - const target = "xmd:0.0"; - const probe = tmuxPaneProbe(socket, target, roots.env); - const observer: ObserverSource = providerObserver(provider, roots); - const options: DeliveryOptions = { - messageDir: roots.messageDir, - bracketedPaste: true, - submitKey: "Enter", + // Read only now, after the scope's finalizers ran: the root is removed, its + // message directory with it, and the provider session was cleaned. + const rootGone = rootPath === "" ? true : !(yield* exists(rootPath)); + const cleanup: CleanupEvidence & { providerSessionRemoved: boolean } = { + storeRemoved: rootGone, + messageFilesRemoved: rootGone, + providerFilesUntouched: true, + providerSessionRemoved, }; - const first = yield* probe.snapshot(); - - yield* store.dispatch({ type: "ReplOpened", replSession: `live-${provider}` }); - yield* store.dispatch({ - type: "RoleBound", - key, - role: provider === "claude" ? "Implementor" : "Reviewer", - issue: "#774", - identity, - paneGeneration: first.generation, - }); - const marker = `MK-${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; - const messageId = `msg-${randomUUID().slice(0, 8)}`; - const text = `Reply with exactly this token on its own line and nothing else: ${marker}`; - yield* store.dispatch({ type: "MessageQueued", key, id: messageId, text, marker }); - - // Attempt until admitted, then observe until the exact user event and its - // completion appear, or a deadline diagnoses a hang and marks it uncertain. - const attempt = yield* driveUntilPasted( - store, - key, - probe, - observer, - options, + + if (outcome.harnessDetail.length > 0 && !outcome.ran) { + return harnessFailed(mode, base, outcome.harnessDetail); + } + return liveReport({ + provider, + mode, + base, + head, + identity: outcome.identity, + sourceHash: outcome.sourceHash, + version: outcome.version, + accepted: outcome.accepted, + completed: outcome.completed, + supportsCompletion: outcome.supportsCompletion, + materializationTurns: outcome.materializationTurns, + nativeTurns: outcome.nativeTurns, counters, deliveries, - ); - let accepted = false; - let completed = false; - if (attempt) { - const deadline = Date.now() + ACCEPT_DEADLINE_MS; - while (Date.now() < deadline) { - yield* observeStep(store, key, observer); - const role = store.state().roles[key]; - const message = role?.messages.find((entry) => entry.id === messageId); - accepted = message?.state === "accepted" || message?.state === "completed"; - completed = message?.state === "completed"; - if (completed) { - break; - } - yield* sleep(POLL_MS); + restart, + cleanup: { + storeRemoved: cleanup.storeRemoved, + messageFilesRemoved: cleanup.messageFilesRemoved, + providerFilesUntouched: cleanup.providerFilesUntouched, + }, + }); + })(); +} + +/** The current commit, for the report's head, or the base when git is unavailable. */ +function currentHead(base: string): Operation<{ sha: string }> { + return (function* (): Operation<{ sha: string }> { + try { + const result = yield* exec("git", { arguments: ["rev-parse", "HEAD"] }).join(); + const sha = result.stdout.trim(); + return { sha: result.code === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : base }; + } catch { + return { sha: base }; + } + })(); +} + +/** Run one provider cleanup command and report whether it succeeded. */ +function runCommand( + command: string, + args: readonly string[], + env: Record, +): Operation { + return (function* (): Operation { + try { + const result = yield* exec(command, { arguments: [...args], env }).join(); + return result.code === 0; + } catch { + return false; + } + })(); +} + +/** Count the materialization turns the launch journal retained for one session. */ +function materializationTurns(journalPath: string, identity: string): Operation { + return (function* (): Operation { + if (!(yield* exists(journalPath))) { + return 0; + } + const text = yield* readTextFile(journalPath); + let count = 0; + for (const raw of text.split("\n")) { + if (raw.trim().length === 0) { + continue; + } + let event: unknown; + try { + event = JSON.parse(raw); + } catch { + continue; + } + if (!isRecord(event) || !isRecord(event.result) || !isRecord(event.result.value)) { + continue; } - if (!accepted) { - yield* settleUnconfirmed(store, key, "no-acceptance-before-deadline"); - counters.uncertain += 1; + const value = event.result.value; + if (value.phase === "materialized" && value.nativeSessionId === identity) { + count += 1; } } + return count; + })(); +} - const version = yield* providerVersion(provider, roots.env); - yield* provide( - liveReport({ - provider, - mode, - base, - identity: key, - version, - accepted, - completed, - counters, - deliveries, - }), - ); - }); +/** A fixed-category description of a harness error, carrying no private detail. */ +function classifyError(error: unknown): string { + return error instanceof Error ? error.name : "unknown-harness-error"; } /** The provider's reported version, or "" when it will not say. */ @@ -636,42 +836,53 @@ function liveReport(inputs: { provider: LiveProvider; mode: ReportMode; base: string; + head: { sha: string }; identity: string; + sourceHash: string; version: string; accepted: boolean; completed: boolean; + supportsCompletion: boolean; + materializationTurns: number; + nativeTurns: number; counters: ReportCounters; deliveries: readonly DeliveryEvidence[]; + restart: RestartEvidence; + cleanup: CleanupEvidence; }): TerminalReplReport { - const safe = - inputs.counters.wrongPaneDeliveries === 0 && - inputs.counters.busyAdmissions === 0 && - inputs.counters.manualActivityAdmissions === 0; - const providerVerdict: ProviderVerdict = !safe - ? "VIEW_ONLY" - : inputs.accepted && inputs.completed - ? "PASS" - : inputs.accepted && inputs.provider === "claude" - ? "PROVIDER_EXCLUDED" - : "VIEW_ONLY"; - const spent = inputs.counters.admittedDeliveries; + const safe = countersSafe(inputs.counters); + const providerVerdict: ProviderVerdict = decideProviderVerdict({ + accepted: inputs.accepted, + completed: inputs.completed, + safe, + supportsCompletion: inputs.supportsCompletion, + }); + // Codex spends a materialization turn plus the marker turn; Claude spends only + // the marker turn. Both are counted from evidence, never assumed. + const spent = + inputs.provider === "codex" + ? inputs.materializationTurns + inputs.nativeTurns + : inputs.nativeTurns; const provider: ProviderReport = { verdict: providerVerdict, versionKnown: inputs.version.length > 0, ...(inputs.version.length > 0 ? { version: inputs.version } : {}), identityHash: identityHash(inputs.identity), - sourceIdentityHash: identityHash(`${inputs.provider}:${inputs.identity}:source`), + ...(inputs.sourceHash.length > 0 ? { sourceIdentityHash: inputs.sourceHash } : {}), accepted: inputs.accepted, completed: inputs.completed, }; const absent: ProviderReport = { verdict: "n/a", versionKnown: false }; + // The single-provider journey's overall verdict is its provider verdict, which + // is never "n/a" here (this provider ran). + const overall: ReportVerdict = providerVerdict === "n/a" ? "VIEW_ONLY" : providerVerdict; return { schema: "terminal-repl-poc-report.v1", - verdict: providerVerdict, + verdict: overall, mode: inputs.mode, runtime: "deno", base: { sha: inputs.base }, - head: { sha: inputs.base }, + head: inputs.head, providers: { claude: inputs.provider === "claude" ? provider : absent, codex: inputs.provider === "codex" ? provider : absent, @@ -685,8 +896,8 @@ function liveReport(inputs: { matrix: [], counters: inputs.counters, deliveries: [...inputs.deliveries], - restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, - cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + restart: inputs.restart, + cleanup: inputs.cleanup, }; } diff --git a/packages/terminal-tmux/poc/repl/observer.ts b/packages/terminal-tmux/poc/repl/observer.ts index b2b2172d1..a4e235ab0 100644 --- a/packages/terminal-tmux/poc/repl/observer.ts +++ b/packages/terminal-tmux/poc/repl/observer.ts @@ -28,7 +28,7 @@ import { until } from "effection"; import type { Operation } from "effection"; -import { readFile, readdir, stat } from "node:fs/promises"; +import { open, readFile, readdir, stat } from "node:fs/promises"; import { join } from "node:path"; import type { NormalizedEvent, Provider } from "./state.ts"; @@ -53,6 +53,14 @@ export interface ObservedSource { /** How one provider reads its own records. The shared observer owns the rest. */ export interface ProviderParser { readonly provider: Provider; + /** + * Whether this build's format has an unambiguous completion record at all. + * + * A capability, not a timing observation: when it is false, the observer never + * yields a completion and the caller reports `PROVIDER_EXCLUDED` from this + * explicit fact rather than from a deadline elapsing. + */ + readonly supportsCompletion: boolean; /** The identity a file name declares, when the provider encodes it there. */ identityFromName(name: string): string | undefined; /** Read one already-parsed JSON record into a normalized classification. */ @@ -185,14 +193,20 @@ function headerMatches( expectedProject: string | undefined, ): Operation { return (function* (): Operation { - let bytes: Uint8Array; + // A bounded prefix read: only the header is inspected, never the whole + // transcript body. A file whose identity record does not fall inside this + // prefix is not treated as a header-identified match. + let text: string; try { - bytes = yield* until(readFile(path)); + text = yield* readPrefix(path, HEADER_PREFIX_BYTES); } catch { return false; } - const text = new TextDecoder().decode(bytes); - for (const line of text.split("\n")) { + // Drop a trailing partial line so a record split by the prefix boundary is + // never parsed half-read. + const newline = text.lastIndexOf("\n"); + const complete = newline < 0 ? "" : text.slice(0, newline); + for (const line of complete.split("\n")) { if (line.trim().length === 0) { continue; } @@ -214,11 +228,10 @@ function headerMatches( if (parsed.identity !== expected) { return false; } - if ( - expectedProject !== undefined && - parsed.project !== undefined && - parsed.project !== expectedProject - ) { + // Fail closed on the project: a required project the header does not + // declare, or declares differently, is not a match. Missing project + // metadata refuses rather than being accepted. + if (expectedProject !== undefined && parsed.project !== expectedProject) { return false; } return true; @@ -349,6 +362,42 @@ function classifyStep(parsed: ParsedRecord, identity: string, key: string): Step } } +/** How many bytes of a file's head are read to find its identity record. */ +const HEADER_PREFIX_BYTES = 65_536; + +/** Read at most `limit` bytes from the start of a file, as UTF-8. */ +function readPrefix(path: string, limit: number): Operation { + return (function* (): Operation { + const handle = yield* until(open(path, "r")); + const buffer = new Uint8Array(limit); + let bytesRead = 0; + let failure: unknown; + try { + ({ bytesRead } = yield* until(handle.read(buffer, 0, limit, 0))); + } catch (error) { + failure = error; + } + // Closed unconditionally after the read, never inside a finally that yields. + yield* until(handle.close()); + if (failure !== undefined) { + throw failure instanceof Error ? failure : new Error(String(failure)); + } + return new TextDecoder().decode(buffer.subarray(0, bytesRead)); + })(); +} + +/** The file's physical byte length, including any partial tail. */ +export function physicalSizeOf(path: string): Operation { + return (function* (): Operation { + try { + const info = yield* until(stat(path)); + return info.size; + } catch { + return 0; + } + })(); +} + /** A stable file-identity token; a change means the file was replaced. */ function fileIdentity(path: string): Operation { return (function* (): Operation { diff --git a/packages/terminal-tmux/poc/repl/report.schema.json b/packages/terminal-tmux/poc/repl/report.schema.json index c5de8e0b6..d506efa7d 100644 --- a/packages/terminal-tmux/poc/repl/report.schema.json +++ b/packages/terminal-tmux/poc/repl/report.schema.json @@ -32,7 +32,7 @@ ] }, "mode": { - "enum": ["deterministic", "live-claude", "live-codex"] + "enum": ["deterministic", "live-claude", "live-codex", "overall"] }, "runtime": { "type": "string" @@ -291,6 +291,56 @@ } }, "allOf": [ + { + "if": { + "properties": { + "verdict": { + "const": "PASS" + } + }, + "required": ["verdict"] + }, + "then": { + "properties": { + "counters": { + "properties": { + "busyAdmissions": { + "const": 0 + }, + "manualActivityAdmissions": { + "const": 0 + }, + "wrongPaneDeliveries": { + "const": 0 + }, + "duplicateDeliveries": { + "const": 0 + } + } + }, + "restart": { + "properties": { + "reExecutions": { + "const": 0 + } + } + }, + "cleanup": { + "properties": { + "storeRemoved": { + "const": true + }, + "messageFilesRemoved": { + "const": true + }, + "providerFilesUntouched": { + "const": true + } + } + } + } + } + }, { "if": { "properties": { @@ -421,6 +471,67 @@ } } } + }, + { + "if": { + "properties": { + "verdict": { + "const": "PASS" + }, + "mode": { + "const": "overall" + } + }, + "required": ["verdict", "mode"] + }, + "then": { + "required": ["head", "deliveries"], + "properties": { + "matrix": { + "minItems": 18, + "not": { + "contains": { + "type": "object", + "required": ["result"], + "properties": { + "result": { + "enum": ["fail", "n/a"] + } + } + } + } + }, + "deliveries": { + "minItems": 1 + }, + "turnBudgets": { + "properties": { + "claudeAuthorized": { + "minimum": 1 + }, + "claudeSpent": { + "minimum": 1 + }, + "codexAuthorized": { + "minimum": 1 + }, + "codexSpent": { + "minimum": 1 + } + } + }, + "providers": { + "properties": { + "claude": { + "$ref": "#/definitions/passProvider" + }, + "codex": { + "$ref": "#/definitions/passProvider" + } + } + } + } + } } ] } diff --git a/packages/terminal-tmux/poc/repl/report.ts b/packages/terminal-tmux/poc/repl/report.ts index a7029fbb8..8b4cf2b84 100644 --- a/packages/terminal-tmux/poc/repl/report.ts +++ b/packages/terminal-tmux/poc/repl/report.ts @@ -39,7 +39,7 @@ export type ReportVerdict = export type ProviderVerdict = "PASS" | "VIEW_ONLY" | "PROVIDER_EXCLUDED" | "NOT_AUTHORIZED" | "n/a"; -export type ReportMode = "deterministic" | "live-claude" | "live-codex"; +export type ReportMode = "deterministic" | "live-claude" | "live-codex" | "overall"; export interface MatrixEntry { readonly id: string; @@ -169,6 +169,146 @@ export function notAuthorizedReport( }; } +/** Whether a counters block admits nothing unsafe. */ +export function countersSafe(counters: ReportCounters): boolean { + return ( + counters.busyAdmissions === 0 && + counters.manualActivityAdmissions === 0 && + counters.wrongPaneDeliveries === 0 && + counters.duplicateDeliveries === 0 + ); +} + +/** + * Decide one provider's verdict from evidence alone. + * + * `PROVIDER_EXCLUDED` is reached only from the explicit capability fact that the + * build has no completion record — never from a deadline. An unsafe admission is + * `VIEW_ONLY` regardless of acceptance. + */ +export function decideProviderVerdict(inputs: { + readonly accepted: boolean; + readonly completed: boolean; + readonly safe: boolean; + readonly supportsCompletion: boolean; +}): ProviderVerdict { + if (!inputs.safe) { + return "VIEW_ONLY"; + } + if (inputs.accepted && inputs.completed) { + return "PASS"; + } + if (inputs.accepted && !inputs.completed && !inputs.supportsCompletion) { + return "PROVIDER_EXCLUDED"; + } + return "VIEW_ONLY"; +} + +/** + * Aggregate the offline matrix and both live provider journeys into one overall + * report. + * + * The overall `PASS` is the conjunction the POC decision requires: RP1–RP18 all + * passing, both providers passing their own authorized journey, every unsafe + * counter zero, no restart re-execution, and verified cleanup. Anything short of + * that is not a `PASS` — a single provider can never make the whole POC pass. + */ +export function aggregateReport( + base: { readonly sha: string; readonly parent?: string }, + head: { readonly sha: string }, + runtime: string, + deterministic: { + readonly matrix: readonly MatrixEntry[]; + readonly counters: ReportCounters; + readonly restart: RestartEvidence; + readonly cleanup: CleanupEvidence; + readonly deliveries: readonly DeliveryEvidence[]; + }, + claude: TerminalReplReport, + codex: TerminalReplReport, +): TerminalReplReport { + const matrixComplete = + deterministic.matrix.length >= 18 && + deterministic.matrix.every((entry) => entry.result === "pass"); + const counters = mergeCounters(deterministic.counters, claude.counters, codex.counters); + const cleanup: CleanupEvidence = { + storeRemoved: + deterministic.cleanup.storeRemoved && + claude.cleanup.storeRemoved && + codex.cleanup.storeRemoved, + messageFilesRemoved: + deterministic.cleanup.messageFilesRemoved && + claude.cleanup.messageFilesRemoved && + codex.cleanup.messageFilesRemoved, + providerFilesUntouched: + deterministic.cleanup.providerFilesUntouched && + claude.cleanup.providerFilesUntouched && + codex.cleanup.providerFilesUntouched, + }; + const restart: RestartEvidence = { + queuedRestored: deterministic.restart.queuedRestored, + uncertainAfterRestart: deterministic.restart.uncertainAfterRestart, + completedRestored: deterministic.restart.completedRestored, + reExecutions: + deterministic.restart.reExecutions + claude.restart.reExecutions + codex.restart.reExecutions, + }; + const cleanupOk = + cleanup.storeRemoved && cleanup.messageFilesRemoved && cleanup.providerFilesUntouched; + const pass = + matrixComplete && + claude.verdict === "PASS" && + codex.verdict === "PASS" && + countersSafe(counters) && + restart.reExecutions === 0 && + cleanupOk; + const verdict: ReportVerdict = pass + ? "PASS" + : claude.verdict === "PROVIDER_EXCLUDED" || codex.verdict === "PROVIDER_EXCLUDED" + ? "PROVIDER_EXCLUDED" + : "VIEW_ONLY"; + return { + schema: REPORT_SCHEMA, + verdict, + mode: "overall", + runtime, + base, + head, + providers: { claude: claude.providers.claude, codex: codex.providers.codex }, + turnBudgets: { + claudeAuthorized: claude.turnBudgets.claudeAuthorized, + claudeSpent: claude.turnBudgets.claudeSpent, + codexAuthorized: codex.turnBudgets.codexAuthorized, + codexSpent: codex.turnBudgets.codexSpent, + }, + matrix: [...deterministic.matrix], + counters, + deliveries: [ + ...deterministic.deliveries, + ...(claude.deliveries ?? []), + ...(codex.deliveries ?? []), + ], + restart, + cleanup, + }; +} + +/** Sum every counter across the offline matrix and the two live journeys. */ +function mergeCounters(...blocks: readonly ReportCounters[]): ReportCounters { + const sum = (pick: (c: ReportCounters) => number) => + blocks.reduce((total, c) => total + pick(c), 0); + return { + convergenceAttempts: sum((c) => c.convergenceAttempts), + admittedDeliveries: sum((c) => c.admittedDeliveries), + refusals: sum((c) => c.refusals), + uncertain: sum((c) => c.uncertain), + duplicateDeliveries: sum((c) => c.duplicateDeliveries), + wrongPaneDeliveries: sum((c) => c.wrongPaneDeliveries), + busyAdmissions: sum((c) => c.busyAdmissions), + manualActivityAdmissions: sum((c) => c.manualActivityAdmissions), + replays: sum((c) => c.replays), + }; +} + /** A counters block with every field at zero. */ export function zeroCounters(): ReportCounters { return { diff --git a/packages/terminal-tmux/poc/repl/store.ts b/packages/terminal-tmux/poc/repl/store.ts index 66cedd926..5597c4fce 100644 --- a/packages/terminal-tmux/poc/repl/store.ts +++ b/packages/terminal-tmux/poc/repl/store.ts @@ -147,6 +147,85 @@ const EntrySchema = z.object({ // read off it: a change to either the schema stops compiling here. const _actionSchema: z.ZodType = ActionSchema; +/** + * Whether an action is a legal transition from `state`, or the reason it is not. + * + * Shape is parsed elsewhere; this is the *transition* check the Architect's + * re-review requires: an `AttemptStarted` for a message that was never queued, a + * `UserAccepted` for one no attempt was started for, a duplicate role or message, + * and the like are refused rather than restored into an impossible state. + */ +export function illegalTransition(state: ReplState, action: ReplAction): string | undefined { + if (action.type === "ReplOpened" || action.type === "ReplClosed") { + return undefined; + } + if (action.type === "RoleBound") { + return state.roles[action.key] === undefined + ? undefined + : `RoleBound for an already-bound role ${action.key}`; + } + const role = state.roles[action.key]; + if (role === undefined) { + return `${action.type} for an unbound role ${action.key}`; + } + const message = (id: string) => role.messages.find((entry) => entry.id === id); + switch (action.type) { + case "MessageQueued": + return message(action.id) === undefined + ? undefined + : `MessageQueued for an existing message ${action.id}`; + case "ConvergenceStarted": { + const found = message(action.id); + return found !== undefined && found.state === "queued" + ? undefined + : `ConvergenceStarted for a message not queued (${action.id})`; + } + case "ConvergenceInvalidated": { + const found = message(action.id); + return found !== undefined && + (found.state === "converging" || found.state === "attempt-started") + ? undefined + : `ConvergenceInvalidated for a message not converging (${action.id})`; + } + case "AttemptStarted": { + const found = message(action.id); + return found !== undefined && (found.state === "queued" || found.state === "converging") + ? undefined + : `AttemptStarted for a message that was never queued (${action.id})`; + } + case "AttemptDeclined": { + const found = message(action.id); + return found !== undefined && + (found.state === "converging" || found.state === "attempt-started") + ? undefined + : `AttemptDeclined for a message not in flight (${action.id})`; + } + case "AttemptUncertain": { + const found = message(action.id); + return found !== undefined && found.state === "attempt-started" + ? undefined + : `AttemptUncertain for a message not attempted (${action.id})`; + } + case "UserAccepted": { + const found = message(action.id); + return found !== undefined && + (found.state === "attempt-started" || found.state === "uncertain") + ? undefined + : `UserAccepted for a message no attempt was started for (${action.id})`; + } + case "AssistantCompleted": { + const found = message(action.id); + return found !== undefined && found.state === "accepted" + ? undefined + : `AssistantCompleted for a message not accepted (${action.id})`; + } + default: + // TerminalObserved, ProviderBusy/Idle, AssistantObserved, ObserverAdvanced, + // PaneUnavailable and ObserverRefused only need the role to exist. + return undefined; + } +} + /** A stored entry's file name: zero-padded so a lexical sort is numeric. */ function recordName(seq: number): string { return `${String(seq).padStart(6, "0")}.json`; @@ -180,6 +259,10 @@ export function useReplStore(dir: string): Operation { yield* ensure(() => published.close()); function* dispatch(action: ReplAction): Operation { + const illegal = illegalTransition(current, action); + if (illegal !== undefined) { + throw new ReplStoreError(`an illegal transition: ${illegal}`); + } const seq = current.nextAction; const entry: StoredAction = { seq, action }; yield* persist(dir, entry); @@ -210,6 +293,7 @@ function* loadLog(dir: string): Operation { entries.push(parseEntry(text, name)); } entries.sort((left, right) => left.seq - right.seq); + let running = emptyState(); for (const [index, entry] of entries.entries()) { if (entry.seq !== index) { throw new ReplStoreError( @@ -218,6 +302,11 @@ function* loadLog(dir: string): Operation { : `a gap before sequence ${entry.seq}`, ); } + const illegal = illegalTransition(running, entry.action); + if (illegal !== undefined) { + throw new ReplStoreError(`a conflicting history at sequence ${entry.seq}: ${illegal}`); + } + running = reduce(running, entry.action); } return entries; } diff --git a/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts index 4ec01ceea..aaacb5051 100644 --- a/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts +++ b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts @@ -70,7 +70,7 @@ export interface FakePane { /** Run `mutate` the next time the fake crosses a barrier (may do async work). */ armBarrier(mutate: () => Operation): void; /** Run `mutate` the next time a buffer is loaded (just before the guarded paste). */ - armLoad(mutate: () => void): void; + armLoad(mutate: () => Operation): void; /** Make the next guarded paste fail its server-side command with this outcome. */ armGuardFailure(kind: "declined" | "uncertain"): void; } @@ -98,7 +98,7 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { const deliveries: FakeDelivery[] = []; const buffers = new Map(); let barrierTrap: (() => Operation) | undefined; - let loadTrap: (() => void) | undefined; + let loadTrap: (() => Operation) | undefined; let guardFailure: "declined" | "uncertain" | undefined; function snapshot(): PaneSnapshot { @@ -134,7 +134,7 @@ export function createFakePane(options: FakePaneOptions = {}): FakePane { const trap = loadTrap; loadTrap = undefined; if (trap !== undefined) { - trap(); + yield* trap(); } const bytes = new TextDecoder().decode(yield* until(readFile(path))); buffers.set(buffer, bytes); diff --git a/packages/terminal-tmux/tests/repl-poc.test.ts b/packages/terminal-tmux/tests/repl-poc.test.ts index 3fc47e39e..8ff9172aa 100644 --- a/packages/terminal-tmux/tests/repl-poc.test.ts +++ b/packages/terminal-tmux/tests/repl-poc.test.ts @@ -26,7 +26,7 @@ import { ensureDir, exists, readTextFile, writeTextFile } from "@effectionx/fs"; import { chmod } from "node:fs/promises"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; -import { until } from "effection"; +import { race, spawn, suspend, until } from "effection"; import type { Operation } from "effection"; import { attemptStep, @@ -35,14 +35,25 @@ import { settleUnconfirmed, } from "../poc/repl/controller.ts"; import type { DeliveryOptions, ObserverSource } from "../poc/repl/controller.ts"; +import { withPreparedDelivery } from "../poc/repl/delivery.ts"; import { purgeStore, ReplStoreError, useReplStore } from "../poc/repl/store.ts"; import type { ReplStore } from "../poc/repl/store.ts"; -import { claudeParser } from "../poc/repl/claude-observer.ts"; +import { claudeParser, createClaudeParser } from "../poc/repl/claude-observer.ts"; import { codexParser } from "../poc/repl/codex-observer.ts"; import { gatesSatisfied, runLiveProof } from "../poc/repl/live-supervisor.ts"; +import { paneProbeOver } from "../poc/repl/live-worker.ts"; +import type { PaneActivity, TmuxCommand } from "../poc/repl/live-worker.ts"; import type { Provider, ReplState } from "../poc/repl/state.ts"; import type { ProviderParser } from "../poc/repl/observer.ts"; -import { identityHash, REPORT_SCHEMA, validateReport, zeroCounters } from "../poc/repl/report.ts"; +import { + aggregateReport, + countersSafe, + decideProviderVerdict, + identityHash, + REPORT_SCHEMA, + validateReport, + zeroCounters, +} from "../poc/repl/report.ts"; import type { DeliveryEvidence, MatrixEntry, @@ -642,6 +653,7 @@ describe("issue #774 — black-box REPL messaging POC", () => { bag.pane.armLoad(() => { bag.pane.setManual(true); bag.pane.clientActivity(); + return until(Promise.resolve()); }); const attempt = yield* attemptStep( bag.store, @@ -652,14 +664,17 @@ describe("issue #774 — black-box REPL messaging POC", () => { ); tally.convergenceAttempts += 1; expect(attempt.outcome).toEqual("declined"); + // The final combined sample runs after the buffer is prepared, so activity + // during preparation is caught before AttemptStarted and before the guard — + // nothing was pasted, and the guarded paste was never even reached. expect(bag.pane.deliveries.length).toEqual(0); - expect(bag.pane.declines).toEqual(1); + expect(bag.pane.declines).toEqual(0); expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); expect( record( "RP10", true, - "same-PID manual activity between convergence and the guard declined the paste; zero bytes sent", + "same-PID manual activity while the buffer loaded declined the attempt before the guard; zero bytes sent", ), ).toEqual(true); }); @@ -1165,6 +1180,379 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(report.verdict).toEqual("NOT_AUTHORIZED"); }); + it("a provider turn opening while the buffer loads refuses before the guard", function* () { + const root = yield* useTempDirectory("xmd-repl-loadseam-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + // A complete provider turn is appended from the buffer-load seam — during + // preparation, after convergence. The final combined sample taken after + // preparation must catch it, so nothing is pasted. + bag.pane.armLoad(() => + appendRecords(bag.path, [codexUser("an interleaved turn"), codexAgent("busy")]), + ); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("declined"); + expect(bag.pane.deliveries.length).toEqual(0); + expect(bag.pane.deliveries.every((delivery) => !delivery.whileBusy)).toEqual(true); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + }); + + it("a partial provider record during the barrier refuses", function* () { + const root = yield* useTempDirectory("xmd-repl-partialbarrier-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + // A record still being written — no newline yet — grows the file physically + // without changing the cursor or the event count. Physical growth is part of + // the sample, so convergence refuses rather than pasting into a turn opening. + const fragment = codexUser("half a turn").slice(0, 12); + bag.pane.armBarrier(() => appendPartial(bag.path, fragment)); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("not-ready"); + expect(bag.pane.deliveries.length).toEqual(0); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + }); + + it("a Codex header without a project is refused, not accepted", function* () { + const root = yield* useTempDirectory("xmd-repl-nocwd-"); + const bag = yield* scaffold(CODEX_KIT, root); + yield* bind(bag); + yield* queue(bag); + // The only file for this identity declares no project. A required project the + // header does not carry fails closed rather than being accepted. + yield* writeRecords(bag.path, [codexMeta(bag.identity.id)]); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("refused"); + if (attempt.outcome === "refused") { + expect(attempt.refusal).toEqual("not-found"); + } + expect(bag.pane.deliveries.length).toEqual(0); + }); + + it("a Claude record without a turn identity refuses observation", function* () { + const root = yield* useTempDirectory("xmd-repl-noturn-"); + const bag = yield* scaffold(CLAUDE_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("pasted"); + // A user record carrying no requestId turn identity cannot be grouped, so it + // is an unsupported shape rather than a silent match. + yield* appendRecords(bag.path, [claudeUser(bag.identity.id, message.text)]); + const observed = yield* observeStep(bag.store, bag.identity.id, bag.observer); + expect(observed.outcome).toEqual("refused"); + if (observed.outcome === "refused") { + expect(observed.refusal).toEqual("unsupported-shape"); + } + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("attempt-started"); + }); + + it("a missing Claude completion record is PROVIDER_EXCLUDED from capability, not a deadline", function* () { + // The decision is made from the explicit capability, never from elapsed time. + expect( + decideProviderVerdict({ + accepted: true, + completed: false, + safe: true, + supportsCompletion: false, + }), + ).toEqual("PROVIDER_EXCLUDED"); + expect( + decideProviderVerdict({ + accepted: true, + completed: false, + safe: true, + supportsCompletion: true, + }), + ).toEqual("VIEW_ONLY"); + expect( + decideProviderVerdict({ + accepted: true, + completed: true, + safe: true, + supportsCompletion: true, + }), + ).toEqual("PASS"); + expect( + decideProviderVerdict({ + accepted: true, + completed: true, + safe: false, + supportsCompletion: true, + }), + ).toEqual("VIEW_ONLY"); + + // And a build whose format lacks the closing record never yields a + // completion: an accepted turn stays accepted, never spuriously completed. + const root = yield* useTempDirectory("xmd-repl-excluded-"); + const bag = yield* scaffold(CLAUDE_KIT, root); + yield* bind(bag); + const message = yield* queue(bag); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + expect(attempt.outcome).toEqual("pasted"); + const noCompletion: ObserverSource = { + parser: createClaudeParser(false), + directory: bag.providerDir, + project: bag.project, + }; + yield* appendRecords(bag.path, [ + claudeUser(bag.identity.id, message.text, "t1"), + claudeAssistant(bag.identity.id, "the answer", "t1"), + claudeResult(bag.identity.id, "t1"), + ]); + yield* observeStep(bag.store, bag.identity.id, noCompletion); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("accepted"); + expect(noCompletion.parser.supportsCompletion).toEqual(false); + }); + + it("a cancelled delivery removes its private file and buffer", function* () { + const root = yield* useTempDirectory("xmd-repl-cancel-"); + const bag = yield* scaffold(CODEX_KIT, root); + const id = "cancel-msg"; + const path = join(bag.messageDir, `${id}.msg`); + // The delivery prepares its file and buffer, then suspends before pasting. + // Halting the scope must run its finalizers: the file and the buffer go. + yield* race([ + withPreparedDelivery( + bag.pane.probe, + { dir: bag.messageDir, id, bytes: "cancel me", bracketedPaste: true, submitKey: "Enter" }, + function* (): Operation { + yield* suspend(); + }, + ), + (function* (): Operation { + return; + })(), + ]); + expect(yield* exists(path)).toEqual(false); + expect(bag.pane.pendingBuffers()).toEqual(0); + expect(bag.pane.deliveries.length).toEqual(0); + }); + + it("the report schema rejects an unsafe, uncleaned or re-executed PASS", function* () { + const claudePass = livePassReport("claude", "2.1.263"); + expect((yield* validateReport(claudePass)).valid).toEqual(true); + + const unsafe: TerminalReplReport = { + ...claudePass, + counters: { ...zeroCounters(), busyAdmissions: 1 }, + }; + expect((yield* validateReport(unsafe)).valid).toEqual(false); + + const dirty: TerminalReplReport = { + ...claudePass, + cleanup: { storeRemoved: false, messageFilesRemoved: true, providerFilesUntouched: true }, + }; + expect((yield* validateReport(dirty)).valid).toEqual(false); + + const reexecuted: TerminalReplReport = { + ...claudePass, + restart: { + queuedRestored: 0, + uncertainAfterRestart: 0, + completedRestored: 0, + reExecutions: 1, + }, + }; + expect((yield* validateReport(reexecuted)).valid).toEqual(false); + }); + + it("an overall PASS requires the full matrix and both provider journeys", function* () { + const fullMatrix: MatrixEntry[] = Array.from({ length: 18 }, (_, index) => ({ + id: `RP${index + 1}`, + result: "pass" as const, + evidence: "aggregated", + })); + const cleanCleanup = { + storeRemoved: true, + messageFilesRemoved: true, + providerFilesUntouched: true, + }; + const noRestart = { + queuedRestored: 0, + uncertainAfterRestart: 0, + completedRestored: 0, + reExecutions: 0, + }; + const deterministic = { + matrix: fullMatrix, + counters: zeroCounters(), + restart: noRestart, + cleanup: cleanCleanup, + deliveries: [{ messageHash: identityHash("m"), byteCount: 4 }], + }; + const overall = aggregateReport( + { sha: BASE_SHA }, + { sha: HEAD_SHA }, + "deno", + deterministic, + livePassReport("claude", "2.1.263"), + livePassReport("codex", "codex-cli 0.153.4"), + ); + expect(overall.verdict).toEqual("PASS"); + expect(overall.mode).toEqual("overall"); + expect(countersSafe(overall.counters)).toEqual(true); + expect((yield* validateReport(overall)).valid).toEqual(true); + + // One provider short of PASS can never make the whole POC pass. + const viewOnlyCodex: TerminalReplReport = { + ...livePassReport("codex", "codex-cli 0.153.4"), + verdict: "VIEW_ONLY", + providers: { + claude: { verdict: "n/a", versionKnown: false }, + codex: { verdict: "VIEW_ONLY", versionKnown: true, version: "codex-cli 0.153.4" }, + }, + }; + const partial = aggregateReport( + { sha: BASE_SHA }, + { sha: HEAD_SHA }, + "deno", + deterministic, + livePassReport("claude", "2.1.263"), + viewOnlyCodex, + ); + expect(partial.verdict).not.toEqual("PASS"); + + // An incomplete matrix can never make it pass either. + const shortMatrix = aggregateReport( + { sha: BASE_SHA }, + { sha: HEAD_SHA }, + "deno", + { ...deterministic, matrix: fullMatrix.slice(0, 17) }, + livePassReport("claude", "2.1.263"), + livePassReport("codex", "codex-cli 0.153.4"), + ); + expect(shortMatrix.verdict).not.toEqual("PASS"); + }); + + it("the live tmux probe reads real activity generations and honors one conditional guard", function* () { + // A fake tmux command seam and a fake activity source drive the *live* probe, + // so the boundary the live worker uses is held to the same guard contract as + // the fake pane — without a real server. + const issued: string[][] = []; + const activity: PaneActivity = { + // deno-lint-ignore require-yield + *read() { + return { outputEvents: 5, clientActivity: 3 }; + }, + }; + const makeRun = + (mode: "pass" | "decline" | "fail" | "unack"): TmuxCommand => + (args) => + (function* () { + issued.push([...args]); + if (args[0] === "display") { + // A pane snapshot: alive, generation %7, pid 4321, ordinary mode. + return { code: 0, stdout: "%7|4321|ttys7|0|0|node" }; + } + if (args[0] === "if-shell") { + const success = args[3] ?? ""; + const nonce = success.slice(success.lastIndexOf(" ") + 1); + if (mode === "pass") { + return { code: 0, stdout: nonce }; + } + if (mode === "decline") { + return { code: 0, stdout: "DECLINED" }; + } + if (mode === "fail") { + return { code: 1, stdout: "" }; + } + return { code: 0, stdout: "ran-but-no-marker" }; + } + return { code: 0, stdout: "" }; + })(); + + const guardOf = () => ({ + generation: 7, + pid: 4321, + terminal: "ttys7", + alive: true, + mode: "", + foregroundProcess: 0, + clientActivity: 3, + outputEvents: 5, + epoch: 8, + }); + + // The snapshot's activity comes from the source, not a history size, and the + // epoch is their sum. + const passProbe = paneProbeOver(makeRun("pass"), "xmd:0.0", activity); + const snap = yield* passProbe.snapshot(); + expect(snap.outputEvents).toEqual(5); + expect(snap.clientActivity).toEqual(3); + expect(snap.epoch).toEqual(8); + expect(snap.generation).toEqual(7); + + // A matching, acknowledged conditional pastes. + yield* passProbe.loadBuffer("buf", "/dev/null"); + const pasted = yield* passProbe.guardedPaste(guardOf(), { + buffer: "buf", + bracketedPaste: true, + submitKey: "Enter", + }); + expect(pasted.outcome).toEqual("pasted"); + + // The single conditional carries every required fact, not the PID alone. + const conditional = issued.find((command) => command[0] === "if-shell"); + const condition = conditional?.[2] ?? ""; + for (const fact of ["pane_id", "pane_pid", "pane_dead", "pane_in_mode"]) { + expect(condition.includes(fact)).toEqual(true); + } + + // The server rejecting the condition declines; a failed command and an + // unacknowledged submit are both uncertain, never pasted-as-proved. + const declined = yield* paneProbeOver(makeRun("decline"), "xmd:0.0", activity).guardedPaste( + guardOf(), + { buffer: "buf", bracketedPaste: true, submitKey: "Enter" }, + ); + expect(declined.outcome).toEqual("declined"); + const failed = yield* paneProbeOver(makeRun("fail"), "xmd:0.0", activity).guardedPaste( + guardOf(), + { + buffer: "buf", + bracketedPaste: true, + submitKey: "Enter", + }, + ); + expect(failed.outcome).toEqual("uncertain"); + const unacked = yield* paneProbeOver(makeRun("unack"), "xmd:0.0", activity).guardedPaste( + guardOf(), + { buffer: "buf", bracketedPaste: true, submitKey: "Enter" }, + ); + expect(unacked.outcome).toEqual("uncertain"); + }); + it("the store refuses malformed, mislabeled, gapped, duplicated and conflicting histories", function* () { const root = yield* useTempDirectory("xmd-repl-store-"); const write = (dir: string, name: string, body: unknown): Operation => @@ -1202,6 +1590,25 @@ describe("issue #774 — black-box REPL messaging POC", () => { const unknown = join(root, "unknown"); yield* write(unknown, "000000.json", { seq: 0, action: { type: "NotARealAction" } }); expect(yield* refuses(unknown)).toEqual(true); + + // A conflicting transition: an AttemptStarted for a message never queued. + const conflicting = join(root, "conflicting"); + yield* write(conflicting, "000000.json", { + seq: 0, + action: { + type: "RoleBound", + key: "k", + role: "Implementor", + issue: "#774", + identity: { provider: "codex", id: "x" }, + paneGeneration: 1, + }, + }); + yield* write(conflicting, "000001.json", { + seq: 1, + action: { type: "AttemptStarted", key: "k", id: "never-queued" }, + }); + expect(yield* refuses(conflicting)).toEqual(true); }); it("a restart interleaved between the user event and its completion resolves once", function* () { @@ -1373,3 +1780,40 @@ function runtimeName(): string { } return "node"; } + +/** A valid single-provider live PASS report, for schema and aggregator rows. */ +function livePassReport(provider: "claude" | "codex", version: string): TerminalReplReport { + const pass = { + verdict: "PASS" as const, + versionKnown: true, + version, + identityHash: identityHash(`${provider}-identity`), + sourceIdentityHash: identityHash(`${provider}-source`), + accepted: true, + completed: true, + }; + const absent = { verdict: "n/a" as const, versionKnown: false }; + return { + schema: REPORT_SCHEMA, + verdict: "PASS", + mode: provider === "claude" ? "live-claude" : "live-codex", + runtime: "deno", + base: { sha: BASE_SHA }, + head: { sha: HEAD_SHA }, + providers: { + claude: provider === "claude" ? pass : absent, + codex: provider === "codex" ? pass : absent, + }, + turnBudgets: { + claudeAuthorized: provider === "claude" ? 1 : 0, + claudeSpent: provider === "claude" ? 1 : 0, + codexAuthorized: provider === "codex" ? 2 : 0, + codexSpent: provider === "codex" ? 2 : 0, + }, + matrix: [], + counters: zeroCounters(), + deliveries: [{ messageHash: identityHash("delivery"), byteCount: 4 }], + restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, + cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + }; +} From 11720a1301acc846f7d6c3767f117f407ec1f81e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 20:14:47 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=92=20Close=20out=20the=20terminal?= =?UTF-8?q?=20REPL=20POC=20as=20VIEW=5FONLY=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result-only closeout, no production/architecture/specification change. - RESULT.md records the VIEW_ONLY verdict and the exact race: a provider turn can open between the final combined sample and the single guarded paste, a window that is not observable before the paste and cannot be atomically refused, so reliable dispatch is not established and tmux delivery stays view-only while reliable REPL interaction remains ACP-owned. - The live-delivery journey is permanently disabled: the supervisor launches no agent and spends no turn under any gate, returning the VIEW_ONLY conclusion; live-worker keeps only the terminal-boundary evidence. The grid launch documents and the two live proof documents are removed. - The deterministic suite records the VIEW_ONLY conclusion in a schema-valid overall report and proves the journey is disabled; it passes under Deno, Node and Bun. - README updated with the conclusion. --- .../poc/repl/ClaudeBlackBoxRepl.test.md | 125 --- .../poc/repl/CodexBlackBoxRepl.test.md | 126 --- packages/terminal-tmux/poc/repl/README.md | 95 +-- packages/terminal-tmux/poc/repl/RESULT.md | 54 ++ .../poc/repl/TerminalReplClaude.md | 24 - .../poc/repl/TerminalReplCodex.md | 24 - .../terminal-tmux/poc/repl/live-supervisor.ts | 137 ++- .../terminal-tmux/poc/repl/live-worker.ts | 802 +----------------- packages/terminal-tmux/tests/repl-poc.test.ts | 90 +- 9 files changed, 214 insertions(+), 1263 deletions(-) delete mode 100644 packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md delete mode 100644 packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md create mode 100644 packages/terminal-tmux/poc/repl/RESULT.md delete mode 100644 packages/terminal-tmux/poc/repl/TerminalReplClaude.md delete mode 100644 packages/terminal-tmux/poc/repl/TerminalReplCodex.md diff --git a/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md b/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md deleted file mode 100644 index 8177fa009..000000000 --- a/packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md +++ /dev/null @@ -1,125 +0,0 @@ -# Black-box REPL messaging to a native Claude pane - -`xmd run` can open a Claude coding agent in a terminal pane. This document asks -the one question the POC exists to answer for Claude: **can generic terminal-state -convergence deliver one literal REPL message into that pane, and can Claude's own -session file confirm the exact message was accepted and answered — without any -hook, plugin, or screen scraping?** - -The journey launches the real grid, waits for the pane's terminal to converge on a -safe input point, pastes one uniquely-marked message as literal bytes, and then -reads Claude's own session file for the exact user event and its completion. The -terminal only ever authorizes the attempt; acceptance is the provider file's word. - -## What this costs, and what it touches - -One Claude model turn against the operator's own credentials. It runs under a -private `HOME` and `TMPDIR`, so `.xmd`, `.acpx`, the adapters, the launch journal, -the tmux server and the POC store are isolated. `CLAUDE_CONFIG_DIR` is left as the -operator has it so Claude stays authenticated; nothing beneath Claude's -configuration or history is swept. Claude's project state for the temporary -directory is cleaned afterward through Claude's own `project purge`. - -It is gated twice over. Without `XMD_TERMINAL_REPL_CLAUDE_PROOF=1` and -`XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1`, the supervisor refuses before -any agent starts and before any transcript is opened, printing a `NOT_AUTHORIZED` -report that spent nothing. That is the branch that runs on an ordinary machine and -in CI. - -Run it, once authorized, with: - -```sh -XMD_TERMINAL_REPL_CLAUDE_PROOF=1 XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1 \ - deno task xmd test packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md --raw -``` - -## What a verdict may say - -The report carries versions, hashes, counters, turn budgets and cleanup outcomes, -and no conversation: no transcript, no reply, no path, no argv, no environment, no -tmux identifier, and no raw native identity. - - - -```json -{ - "type": "object", - "required": ["report", "schemaValid"], - "properties": { - "report": { - "type": "object", - "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], - "properties": { - "schema": { "const": "terminal-repl-poc-report.v1" }, - "verdict": { - "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] - }, - "mode": { "type": "string" }, - "detail": { "type": "string" }, - "turnBudgets": { "type": "object" }, - "counters": { "type": "object" } - } - }, - "schemaValid": { "type": "boolean" }, - "errors": { "type": "array" } - } -} -``` - - - - - -```sh timeout=25min exec as="run" -deno run --allow-all packages/terminal-tmux/poc/repl/live-supervisor.ts claude -``` - -The supervisor returns a structured report and exits zero even when it refuses, so -a nonzero exit means the supervisor itself broke rather than a question being -answered. - - - - -{run.stdout} - - -The supervisor validated the report against the checked-in -`report.schema.json` — the full validator — before printing it, so an invalid -report fails here rather than being read past. - - - -The whole report is shown before anything is judged. - -```json -{run.stdout} -``` - - - - -The exact marked message was accepted under the intended native identity and the -turn completed, observed from Claude's own session file and never from the screen. - - - - - - - - -The ordinary path: without both gates nothing started and no turn was spent. This -is the pass on a developer machine and in CI. - - - - - - - - - - - - diff --git a/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md b/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md deleted file mode 100644 index ecdf3d0f3..000000000 --- a/packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md +++ /dev/null @@ -1,126 +0,0 @@ -# Black-box REPL messaging to a native Codex pane - -`xmd run` can open a Codex coding agent in a terminal pane. This document asks the -one question the POC exists to answer for Codex: **can generic terminal-state -convergence deliver one literal REPL message into that pane, and can Codex's own -rollout file confirm the exact message was accepted and answered — without any -hook, plugin, or screen scraping?** - -The journey launches the real grid, waits for the pane's terminal to converge on a -safe input point, pastes one uniquely-marked message as literal bytes, and then -reads Codex's own rollout file for the exact user event and its completion. The -terminal only ever authorizes the attempt; acceptance is the provider file's word. - -## What this costs, and what it touches - -At most two Codex model turns against the operator's own credentials: one if the -thread is already resumable, otherwise one materialization turn and one marker -turn. It runs under a private `HOME` and `TMPDIR`, so `.xmd`, `.acpx`, the -adapters, the launch journal, the tmux server and the POC store are isolated. -`CODEX_HOME` is left as the operator has it so Codex stays authenticated; nothing -beneath it is swept. The conversation this run creates is removed afterward through -Codex's own `delete`. - -It is gated twice over. Without `XMD_TERMINAL_REPL_CODEX_PROOF=1` and -`XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2`, the supervisor refuses before -any agent starts and before any transcript is opened, printing a `NOT_AUTHORIZED` -report that spent nothing. That is the branch that runs on an ordinary machine and -in CI. - -Run it, once authorized, with: - -```sh -XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 \ - deno task xmd test packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md --raw -``` - -## What a verdict may say - -The report carries versions, hashes, counters, turn budgets and cleanup outcomes, -and no conversation: no transcript, no reply, no path, no argv, no environment, no -tmux identifier, and no raw native identity. - - - -```json -{ - "type": "object", - "required": ["report", "schemaValid"], - "properties": { - "report": { - "type": "object", - "required": ["schema", "verdict", "mode", "turnBudgets", "counters"], - "properties": { - "schema": { "const": "terminal-repl-poc-report.v1" }, - "verdict": { - "enum": ["PASS", "VIEW_ONLY", "PROVIDER_EXCLUDED", "ENVIRONMENT_BLOCKED", "HARNESS_FAILED", "NOT_AUTHORIZED"] - }, - "mode": { "type": "string" }, - "detail": { "type": "string" }, - "turnBudgets": { "type": "object" }, - "counters": { "type": "object" } - } - }, - "schemaValid": { "type": "boolean" }, - "errors": { "type": "array" } - } -} -``` - - - - - -```sh timeout=25min exec as="run" -deno run --allow-all packages/terminal-tmux/poc/repl/live-supervisor.ts codex -``` - -The supervisor returns a structured report and exits zero even when it refuses, so -a nonzero exit means the supervisor itself broke rather than a question being -answered. - - - - -{run.stdout} - - -The supervisor validated the report against the checked-in -`report.schema.json` — the full validator — before printing it, so an invalid -report fails here rather than being read past. - - - -The whole report is shown before anything is judged. - -```json -{run.stdout} -``` - - - - -The exact marked message was accepted under the intended native identity and the -turn completed, observed from Codex's own rollout file and never from the screen. - - - - - - - - -The ordinary path: without both gates nothing started and no turn was spent. This -is the pass on a developer machine and in CI. - - - - - - - - - - - - diff --git a/packages/terminal-tmux/poc/repl/README.md b/packages/terminal-tmux/poc/repl/README.md index a9815f656..7954013ee 100644 --- a/packages/terminal-tmux/poc/repl/README.md +++ b/packages/terminal-tmux/poc/repl/README.md @@ -1,73 +1,62 @@ -# Black-box terminal REPL POC (issue #774) +# Black-box terminal REPL POC (issue #774) — result: VIEW_ONLY A finite, disposable proof — not the production REPL, and not part of any package -export. It answers one question: +export. It asked one question: > Can generic terminal-state convergence make black-box tmux input delivery > reliable enough while provider session files supply authoritative acceptance and > completion? +**The decision is VIEW_ONLY.** Passive session-file observation is sound, but +reliable message dispatch cannot be established over black-box tmux input and +stays ACP-owned. The reasoning and the exact race are in [`RESULT.md`](./RESULT.md). + Nothing here is exported from `@executablemd/terminal-tmux`. It is reached only by -the deterministic evidence in `packages/terminal-tmux/tests/repl-poc.test.ts` and, -under explicit authorization, by the live supervisor here. It adds no Workflow -capability, no journal record, no replay rule, and no workflow syntax. +the deterministic evidence in `packages/terminal-tmux/tests/repl-poc.test.ts`. It +adds no Workflow capability, no journal record, no replay rule, and no workflow +syntax, and it changes no production, architecture or specification file. -## The pieces +## What the evidence establishes -- `state.ts`, `actions.ts` — one Flux-style immutable state and its closed action - vocabulary. Actions are the only way state changes; the reducer is the only place - a transition is written. -- `store.ts` — a sequence-numbered, staged-write action log. Restart replays it and - refuses a gap, a duplicate or a malformed record. +- `state.ts`, `actions.ts`, `store.ts` — a Flux-style immutable store with a + closed action vocabulary and a sequence-numbered, staged-write log that replays + on restart and refuses gaps, duplicates, malformed records and illegal + transitions. - `observer.ts` with `claude-observer.ts` and `codex-observer.ts` — a strict, - read-only provider session-file boundary. It matches only the exact native - identity, advances the cursor only past a complete record, and refuses on - ambiguity, truncation, rotation, identity mismatch or an unsupported shape. It - never writes to a provider file. -- `convergence.ts` — the generic terminal-convergence algorithm. No prompt or - screen-text parsing: two structurally equal snapshots across an acknowledged - barrier, no intervening event, and no open provider turn. + read-only session-file observer. It locates by exact native identity and project + from bounded header reads, advances the cursor only past a complete record, + groups Claude output by its `requestId` turn, and refuses ambiguity, truncation, + rotation, identity mismatch and unsupported shapes. It never writes a provider + file. **This is the reusable outcome.** +- `convergence.ts` — the generic terminal-convergence algorithm. No screen-text + parsing: two structurally equal pane samples across an acknowledged barrier, + with the provider's open-turn, cursor, event count and physical size unchanged. - `delivery.ts` — literal delivery through a private `0600` file and a uniquely - named tmux buffer, with a separate submit key. Message bytes never enter a shell - or a tmux argument vector. -- `controller.ts` — the message lifecycle: converge, record intent durably, paste - under a final guard, then confirm acceptance from the provider file. An unproved - outcome becomes uncertain and is never pasted again. + named tmux buffer, prepared before the final sample, with a separate submit key. +- `controller.ts` — the message lifecycle: converge, prepare, final sample, + durable `AttemptStarted`, one guarded paste, then acceptance from the provider + file. Any unproved outcome becomes uncertain and is never retried. - `report.ts` with `report.schema.json` — the `terminal-repl-poc-report.v1` - artifact and its validator. It carries hashes, counters, versions, turn budgets, - the RP matrix and restart/cleanup evidence, and no conversation content. -- `live-supervisor.ts`, `live-worker.ts`, `TerminalReplClaude.md`, - `TerminalReplCodex.md`, `ClaudeBlackBoxRepl.test.md`, `CodexBlackBoxRepl.test.md` — the gated live - journey. It refuses before starting any agent or opening any transcript unless - both of its exact gates are supplied. - -## The deterministic matrix + artifact, its validator, and the overall aggregator whose `PASS` was reachable + only with both live provider journeys. +- `live-worker.ts` — the terminal boundary kept as evidence: the pane probe over + an injectable tmux command seam and a real control-mode activity source, with + the single conditional guard the boundary would use. -`packages/terminal-tmux/tests/repl-poc.test.ts` freezes RP1–RP18 and runs them -against fake panes and synthetic append-only session files. The fake pane exposes -hidden busy and manual ground truth only to the assertions, never to the -algorithm, so a paste admitted while the pane was busy or a person was typing is -caught. Run it with: - -```sh -deno task test packages/terminal-tmux/tests/repl-poc.test.ts -``` +## The live journey is closed out -## The live journey +The POC reached its decision without a live model turn, and the live-delivery +journey is permanently disabled. `live-supervisor.ts`'s `runLiveProof` launches no +coding agent and spends no turn under any environment; it returns the VIEW_ONLY +conclusion. The grid launch documents and the two live proof documents have been +removed. -The live journey never runs in ordinary CI and spends real model turns. It is gated -twice over per provider, and previous authorization does not count: +## Running the evidence ```sh -XMD_TERMINAL_REPL_CLAUDE_PROOF=1 XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1 \ - deno task xmd test packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md --raw - -XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 \ - deno task xmd test packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md --raw +deno task test packages/terminal-tmux/tests/repl-poc.test.ts ``` -Without both exact values the supervisor prints a `NOT_AUTHORIZED` report and starts -nothing. Each provider has its own single-pane grid document, so authorizing one -provider can never launch the other. The live journey body in `live-worker.ts` is -unexercised until an authorized run, which is the only context allowed to spend the -turns it needs. +The deterministic suite freezes RP1–RP18 plus supporting boundary rows and passes +under Deno, Node and Bun. It records the VIEW_ONLY conclusion in a schema-valid +overall report. diff --git a/packages/terminal-tmux/poc/repl/RESULT.md b/packages/terminal-tmux/poc/repl/RESULT.md new file mode 100644 index 000000000..0487ef81f --- /dev/null +++ b/packages/terminal-tmux/poc/repl/RESULT.md @@ -0,0 +1,54 @@ +# Result: VIEW_ONLY + +Issue #774 asked whether generic terminal-state convergence can make black-box +tmux input delivery reliable enough while provider session files supply +authoritative acceptance and completion. After the deterministic evidence and two +architecture reviews, the POC's decision is **VIEW_ONLY**. + +- **Passive observation works.** A strict, read-only observer can follow a + Claude session file and a Codex rollout file, match the exact native identity + and project, advance a durable cursor only past a complete record, and refuse + ambiguity, truncation, rotation, identity mismatch and unsupported shapes. That + half is sound and is the reusable outcome. +- **Reliable dispatch is not established.** Terminal convergence can only ever + authorize an attempt; it cannot guarantee one. + +## The exact race + +The controller samples the pane and the provider across an acknowledged barrier, +prepares the private file and tmux buffer, then takes one final combined sample +before recording `AttemptStarted` and pasting. Every observable change up to that +final sample refuses with zero paste. + +What cannot be closed is the interval **between that final sample and the single +guarded paste**. A provider turn can open in that window. It is not observable +before the paste, and a tmux-only guard — which can atomically recheck pane +generation, process, liveness and mode, but not the provider's session file — +cannot atomically refuse it. A delivery admitted there is only ever settled as +`uncertain` after the fact, never proved safe before the bytes are sent. + +Because a safe input point cannot be identified often enough to guarantee no +delivery lands on an open turn, tmux panes remain **view-only** for coordinated +work, and reliable REPL interaction stays **ACP-owned**. + +## What this PR is + +Evidence, not a shipped REPL. It contains: + +- the deterministic RP1–RP18 matrix plus supporting boundary rows, all passing + under Deno, Node and Bun, that prove the observation, convergence, store and + report contracts and the safe refusals; +- the report schema and aggregator whose overall `PASS` was, by design, + reachable only with both live provider journeys — which are not run; +- no production, architecture, specification, dependency or lockfile change. + +## What was removed at closeout + +The live-delivery journeys are permanently disabled. The supervisor no longer +launches a coding agent or delivers a message under any gate; it returns this +`VIEW_ONLY` conclusion. The grid launch documents and the two live proof +documents are removed. No Claude or Codex model turn was ever spent. + +A production retained REPL and action store are not authorized by this result. +Should coordinated multi-agent messaging be pursued, it belongs on ACP, whose +exchanges XMD already drives reliably, rather than on black-box tmux input. diff --git a/packages/terminal-tmux/poc/repl/TerminalReplClaude.md b/packages/terminal-tmux/poc/repl/TerminalReplClaude.md deleted file mode 100644 index 218751931..000000000 --- a/packages/terminal-tmux/poc/repl/TerminalReplClaude.md +++ /dev/null @@ -1,24 +0,0 @@ -# Terminal REPL live grid — Claude only - -This document is what an authorized **Claude** live journey launches. It opens one -`xmd run` terminal grid with a single Claude pane in its own native interface. -Because it contains no Codex pane, authorizing the Claude journey can never start -Codex. - -The REPL POC never modifies the agent; it watches Claude's own session file and -delivers one literal message once the pane's terminal state has converged. It is -launched only by the POC's live supervisor, under a private `HOME` and `TMPDIR`, -so the grid's tmux server, the launch journal, and the POC store are isolated from -the operator's own work. The pane's logical session name is stable; isolation -comes from the private temporary project directory the supervisor runs this in. - - - - - -You are the Implementor pane in a black-box REPL messaging check. Wait for a -message. When one arrives, follow it exactly and reply on a single line. - - - - diff --git a/packages/terminal-tmux/poc/repl/TerminalReplCodex.md b/packages/terminal-tmux/poc/repl/TerminalReplCodex.md deleted file mode 100644 index 4bb1ca322..000000000 --- a/packages/terminal-tmux/poc/repl/TerminalReplCodex.md +++ /dev/null @@ -1,24 +0,0 @@ -# Terminal REPL live grid — Codex only - -This document is what an authorized **Codex** live journey launches. It opens one -`xmd run` terminal grid with a single Codex pane in its own native interface. -Because it contains no Claude pane, authorizing the Codex journey can never start -Claude. - -The REPL POC never modifies the agent; it watches Codex's own rollout file and -delivers one literal message once the pane's terminal state has converged. It is -launched only by the POC's live supervisor, under a private `HOME` and `TMPDIR`, -so the grid's tmux server, the launch journal, and the POC store are isolated from -the operator's own work. The pane's logical session name is stable; isolation -comes from the private temporary project directory the supervisor runs this in. - - - - - -You are the Reviewer pane in a black-box REPL messaging check. Wait for a -message. When one arrives, follow it exactly and reply on a single line. - - - - diff --git a/packages/terminal-tmux/poc/repl/live-supervisor.ts b/packages/terminal-tmux/poc/repl/live-supervisor.ts index 1e35c3aae..1a0518568 100644 --- a/packages/terminal-tmux/poc/repl/live-supervisor.ts +++ b/packages/terminal-tmux/poc/repl/live-supervisor.ts @@ -1,73 +1,32 @@ /** - * Issue #774 POC — the doubly-gated live-proof supervisor. + * Issue #774 POC — the closed-out live supervisor. * - * A live journey launches a real coding agent and spends real model turns, so it - * is refused unless both of its exact gates are supplied for this invocation. - * The gate check is the first thing that happens: before it passes, nothing here - * starts an agent, opens a provider transcript, or reads anything a provider - * owns. Without the gates the supervisor prints a `NOT_AUTHORIZED` report and - * exits cleanly, which is what runs on an ordinary machine and in CI. - * - * Previous authorization does not count. Each provider's gates are read from the - * live environment of this exact invocation, and the turn-count gate must carry - * the exact value the journey is allowed to spend — one for Claude, two for - * Codex. - * - * When the gates are present, the journey itself lives in `live-worker.ts`; this - * module only decides whether it may run and shapes the report either way. + * The POC concluded VIEW_ONLY (see `RESULT.md`): reliable message dispatch cannot + * be established over black-box tmux input and stays ACP-owned. The live-delivery + * journey is therefore permanently disabled. `runLiveProof` launches no coding + * agent, opens no transcript, spends no model turn, and reads no gate: under any + * environment it returns the VIEW_ONLY conclusion. The gates that once armed a + * paid run are gone, so there is nothing to authorize. */ import { main } from "effection"; import type { Operation } from "effection"; import process from "node:process"; -import { notAuthorizedReport, validateReport } from "./report.ts"; +import { validateReport } from "./report.ts"; import type { ReportMode, TerminalReplReport } from "./report.ts"; -import { runLiveJourney } from "./live-worker.ts"; -/** The provider a live journey targets. */ +/** The provider a (now disabled) live journey would have targeted. */ export type LiveProvider = "claude" | "codex"; -/** One provider's exact gates and the turn value its journey may spend. */ -interface Gate { - readonly proofEnv: string; - readonly turnsEnv: string; - readonly turnsValue: string; - readonly mode: ReportMode; -} - -const GATES: Readonly> = { - claude: { - proofEnv: "XMD_TERMINAL_REPL_CLAUDE_PROOF", - turnsEnv: "XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED", - turnsValue: "1", - mode: "live-claude", - }, - codex: { - proofEnv: "XMD_TERMINAL_REPL_CODEX_PROOF", - turnsEnv: "XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED", - turnsValue: "2", - mode: "live-codex", - }, -}; - -/** Whether both of a provider's gates are supplied with their exact values. */ -export function gatesSatisfied( - provider: LiveProvider, - env: Record, -): boolean { - const gate = GATES[provider]; - return env[gate.proofEnv] === "1" && env[gate.turnsEnv] === gate.turnsValue; -} +/** The base commit this POC was built from, recorded in every report. */ +const BASE_SHA = "97fda6aa7b5f85db747c066898fd3ef3c6d1dbeb"; -/** The two exact commands a reviewer runs to authorize each live journey. */ -export const LIVE_COMMANDS: Readonly> = { - claude: - "XMD_TERMINAL_REPL_CLAUDE_PROOF=1 XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED=1 " + - "deno task xmd test packages/terminal-tmux/poc/repl/ClaudeBlackBoxRepl.test.md --raw", - codex: - "XMD_TERMINAL_REPL_CODEX_PROOF=1 XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED=2 " + - "deno task xmd test packages/terminal-tmux/poc/repl/CodexBlackBoxRepl.test.md --raw", -}; +/** The one-line reason dispatch was not established, carried in the report. */ +const RACE_DETAIL = + "VIEW_ONLY: a provider turn can open between the final combined sample and the " + + "single guarded paste; that window is not observable before the paste and cannot " + + "be atomically refused, so reliable dispatch is not established and tmux delivery " + + "stays view-only while reliable REPL interaction remains ACP-owned."; /** The runtime this supervisor runs under, for the report's provenance. */ function runtimeName(): string { @@ -81,31 +40,57 @@ function runtimeName(): string { return "node"; } +/** The VIEW_ONLY closeout report for one provider. No agent, no turn. */ +export function viewOnlyCloseout(provider: LiveProvider, base: string): TerminalReplReport { + const mode: ReportMode = provider === "claude" ? "live-claude" : "live-codex"; + const target = { verdict: "VIEW_ONLY" as const, versionKnown: false }; + const absent = { verdict: "n/a" as const, versionKnown: false }; + return { + schema: "terminal-repl-poc-report.v1", + verdict: "VIEW_ONLY", + mode, + runtime: runtimeName(), + detail: RACE_DETAIL, + base: { sha: base }, + providers: { + claude: provider === "claude" ? target : absent, + codex: provider === "codex" ? target : absent, + }, + turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, + matrix: [], + counters: { + convergenceAttempts: 0, + admittedDeliveries: 0, + refusals: 0, + uncertain: 0, + duplicateDeliveries: 0, + wrongPaneDeliveries: 0, + busyAdmissions: 0, + manualActivityAdmissions: 0, + replays: 0, + }, + restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, + cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, + }; +} + /** - * Decide whether a live journey may run, and produce its report either way. + * The live proof, permanently disabled. * - * The gate check comes first and touches nothing a provider owns. A refusal - * returns a `NOT_AUTHORIZED` report that spent no turn; a pass hands off to the - * journey. + * Regardless of the environment, this launches nothing and returns the VIEW_ONLY + * conclusion — the delivery journey the POC used to gate is gone. */ +// deno-lint-ignore require-yield export function runLiveProof( provider: LiveProvider, - env: Record, + _env: Record, base: string, ): Operation { return (function* (): Operation { - const gate = GATES[provider]; - if (!gatesSatisfied(provider, env)) { - return notAuthorizedReport(gate.mode, runtimeName(), base); - } - // Only past the gate does anything provider-facing begin. - return yield* runLiveJourney(provider, env, base); + return viewOnlyCloseout(provider, base); })(); } -/** The base commit this POC was built from, recorded in every report. */ -const BASE_SHA = "97fda6aa7b5f85db747c066898fd3ef3c6d1dbeb"; - /** Parse the provider argument, refusing anything but the two supported names. */ function providerArgument(argv: readonly string[]): LiveProvider | undefined { const [name] = argv; @@ -116,8 +101,8 @@ function providerArgument(argv: readonly string[]): LiveProvider | undefined { } // Runnable under Deno as `deno run ... live-supervisor.ts `. It -// prints one report as JSON and exits 0; a nonzero exit means the supervisor -// itself broke, never that a journey reached a verdict. +// prints the VIEW_ONLY closeout report and the full-schema validation result, and +// exits 0. It starts no agent under any environment. if (import.meta.main) { await main(function* (): Operation { const provider = providerArgument(process.argv.slice(2)); @@ -128,10 +113,6 @@ if (import.meta.main) { return; } const report = yield* runLiveProof(provider, process.env, BASE_SHA); - // The report is validated against the checked-in schema — the full validator, - // not a weaker inline subset — before it is printed. The document proof reads - // both the report and this verdict, so a report that failed the schema fails - // the document. const validation = yield* validateReport(report); const schemaValid = validation.valid; process.stdout.write( diff --git a/packages/terminal-tmux/poc/repl/live-worker.ts b/packages/terminal-tmux/poc/repl/live-worker.ts index 6ac90dc9b..51949bf0d 100644 --- a/packages/terminal-tmux/poc/repl/live-worker.ts +++ b/packages/terminal-tmux/poc/repl/live-worker.ts @@ -1,209 +1,32 @@ /** - * Issue #774 POC — the live journey, run only under fresh authorization. + * Issue #774 POC — the terminal-boundary evidence, after the VIEW_ONLY closeout. * - * This is the code the supervisor reaches once both gates are satisfied. It is - * deliberately never exercised during implementation or ordinary CI: it launches - * the exact compiled `./dist/xmd run` command, which opens a real terminal grid - * with real coding agents, and it spends real model turns. Its correctness is - * settled by the authorized live run, not by the offline suite, so it is written - * to be coherent and refusal-first and is left for that run to harden. + * The POC concluded VIEW_ONLY (see `RESULT.md`): passive session-file observation + * is sound, but reliable message dispatch cannot be established over black-box + * tmux input and stays ACP-owned. The live-delivery journey that launched a real + * coding agent and pasted a message is therefore removed — no agent is launched + * and no model turn is spent by anything here. * - * What it does when authorized: - * - * - It builds a private `HOME` and `TMPDIR`, so `.xmd`, `.acpx`, the adapters, - * the launch journal, the tmux server and the POC store are all isolated. It - * keeps access to the operator's authenticated provider configuration by - * pointing `CLAUDE_CONFIG_DIR` / `CODEX_HOME` at the operator's real ones - * rather than relocating or copying them — relocating those de-authenticates - * the agent. - * - It launches `./dist/xmd run --journal ` under - * a private pseudo-terminal — one document per provider, so authorizing one - * provider can never start the other — discovers the grid's tmux socket beneath - * the owned - * `TMPDIR`, and reads each role's exact native identity from the isolated - * launch journal — never from directory recency. - * - It drives one uniquely-marked message per role through the same controller - * the offline suite proves, over a real tmux `PaneProbe`, and observes - * acceptance and completion from the provider's own session file. - * - It cleans provider state through the provider's exact supported operation - * (`claude project purge`, `codex delete`) and removes only the roots it owns. - * It never sweeps a shared provider directory. - * - * One boundary the plan's "discover only beneath the POC-owned TMPDIR" cannot - * cover: a provider writes its session file under its own configuration, not - * under `TMPDIR`. Those files are therefore located by the exact identity the - * launch journal retained and by the exact temporary project path, and are never - * swept — the same discipline the repository's own real-agent proofs use. + * What remains is the terminal boundary as evidence: the pane probe the + * convergence algorithm speaks to, expressed over an injectable tmux command + * seam, and a real activity source that reads the server's own control-mode + * generations. `guardedPaste` is the single server-side conditional the boundary + * would use — it rechecks the pane and pastes or declines in one command — and it + * is exercised by the deterministic boundary tests, never against a live agent. */ -import { ensure, race, resource, scoped, sleep, spawn, until, withResolvers } from "effection"; -import type { Operation } from "effection"; import { exec } from "@effectionx/process"; import { lines } from "@effectionx/stream-helpers"; -import { ensureDir, exists, readTextFile, readdir, rm } from "@effectionx/fs"; -import { chmod, copyFile, realpath } from "node:fs/promises"; -import { mkdtempSync } from "node:fs"; -import { spawn as spawnChild } from "node:child_process"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomUUID } from "node:crypto"; -import process from "node:process"; +import { resource, spawn } from "effection"; +import type { Operation } from "effection"; import type { GuardOutcome, PaneProbe, PaneSnapshot, PasteRequest } from "./convergence.ts"; -import { attemptStep, observeStep, reconcileRestart, settleUnconfirmed } from "./controller.ts"; -import type { DeliveryOptions, ObserverSource } from "./controller.ts"; -import { useReplStore } from "./store.ts"; -import type { ReplStore } from "./store.ts"; -import { claudeParser } from "./claude-observer.ts"; -import { locate } from "./observer.ts"; -import { codexParser } from "./codex-observer.ts"; -import type { LiveProvider } from "./live-supervisor.ts"; -import type { - CleanupEvidence, - DeliveryEvidence, - ProviderReport, - ProviderVerdict, - ReportCounters, - ReportMode, - ReportVerdict, - RestartEvidence, - TerminalReplReport, -} from "./report.ts"; -import { countersSafe, decideProviderVerdict, identityHash, zeroCounters } from "./report.ts"; - -/** The repository root, four levels up from this module. */ -const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); -/** The exact compiled binary the live journey launches. */ -const XMD_BINARY = join(REPO_ROOT, "dist", "xmd"); -/** The live grid document this journey runs, one per provider so authorizing - * one provider can never launch the other. */ -function liveDocument(provider: LiveProvider): string { - const name = provider === "claude" ? "TerminalReplClaude.md" : "TerminalReplCodex.md"; - return fileURLToPath(new URL(`./${name}`, import.meta.url)); -} - -/** The isolated roots one live journey owns. */ -interface Roots { - /** The POC-owned temporary root; everything below is removed with it. */ - readonly root: string; - readonly home: string; - readonly tmp: string; - readonly project: string; - readonly journal: string; - readonly storeDir: string; - readonly messageDir: string; - /** The environment handed to the launched child. */ - readonly env: Record; -} - -/** - * Build the isolated roots for a live journey. - * - * Private `HOME` and `TMPDIR` isolate everything XMD owns; the operator's real - * provider configuration is reached by its own variable so the agent stays - * authenticated. The whole root is removed when the scope ends. - */ -export function useIsolatedRoots(): Operation { - return resource(function* (provide) { - const operatorHome = process.env.HOME ?? homedir(); - // Created synchronously so nothing can suspend between naming the root and - // registering its removal — an asynchronous create halted mid-flight would - // leave a directory nothing owns. - // oxlint-disable-next-line local/no-sync-filesystem - const root = mkdtempSync(join(tmpdir(), "xmd-repl-live-")); - yield* ensure(() => rm(root, { recursive: true, force: true })); - yield* until(chmod(root, 0o700)); - - const home = join(root, "home"); - const tmp = join(root, "tmp"); - const project = join(root, "project"); - const storeDir = join(root, "store"); - const messageDir = join(root, "messages"); - yield* ensureDir(home); - yield* ensureDir(tmp); - yield* ensureDir(join(project, ".agents")); - yield* ensureDir(messageDir); - yield* until(chmod(messageDir, 0o700)); - - // A byte-for-byte copy of the role documents, so the production target - // resolves without creating provider state for the repository itself. - yield* until(copyFile(join(REPO_ROOT, "AGENTS.md"), join(project, "AGENTS.md"))); - yield* until( - copyFile( - join(REPO_ROOT, ".agents", "implementor.md"), - join(project, ".agents", "implementor.md"), - ), - ); - - const env: Record = {}; - for (const [name, value] of Object.entries(process.env)) { - if (typeof value === "string") { - env[name] = value; - } - } - env.HOME = home; - env.TMPDIR = tmp; - // Keep the operator's authenticated provider configuration reachable without - // relocating or copying it. Left as the operator set it when already set. - env.CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? join(operatorHome, ".claude"); - env.CODEX_HOME = process.env.CODEX_HOME ?? join(operatorHome, ".codex"); - - yield* provide({ - root, - home, - tmp, - project: yield* until(realpath(project)), - journal: join(root, "launch-journal.jsonl"), - storeDir, - messageDir, - env, - }); - }); -} - -/** Find the grid's private tmux socket beneath the owned TMPDIR, if it exists. */ -export function discoverGridSocket(tmp: string): Operation { - return (function* (): Operation { - let names: string[]; - try { - names = yield* readdir(tmp); - } catch { - return undefined; - } - for (const name of names) { - if (name.startsWith("xmd-grid-")) { - const socket = join(tmp, name, "s"); - if (yield* exists(socket)) { - return socket; - } - } - } - return undefined; - })(); -} - -/** Run one tmux command on the private server and return its code and stdout. */ -function tmuxExec( - socket: string, - args: readonly string[], - env: Record, -): Operation<{ code: number; stdout: string }> { - return (function* (): Operation<{ code: number; stdout: string }> { - const result = yield* exec("tmux", { - arguments: ["-S", socket, "-f", "/dev/null", ...args], - env, - }).join(); - return { code: result.code ?? -1, stdout: result.stdout.trim() }; - })(); -} /** - * One tmux command run against the private server, and its acknowledged result. + * One tmux command run against a server, and its acknowledged result. * - * The single seam the live pane probe is built on. The journey supplies the real - * one (an `exec` of `tmux`); a test supplies a fake that returns canned command - * outputs, so the probe's guard contract is exercised without a real server and - * the fake and the live boundary enforce exactly the same contract. + * The single seam the pane probe is built on. A test supplies a fake that returns + * canned command outputs, so the probe's guard contract is exercised without a + * real server. */ export type TmuxCommand = (args: readonly string[]) => Operation<{ code: number; stdout: string }>; @@ -211,9 +34,7 @@ export type TmuxCommand = (args: readonly string[]) => Operation<{ code: number; * The pane's real output and visible-client activity generations. * * Counted from the server's own control-mode events — `%output` and the - * `%client-*` family — not from `history_size` or a timestamp. A change in either - * generation between convergence and the final combined sample invalidates the - * attempt. + * `%client-*` family — not from `history_size` or a timestamp. */ export interface PaneActivity { read(): Operation<{ outputEvents: number; clientActivity: number }>; @@ -224,7 +45,7 @@ export interface PaneActivity { * * `guardedPaste` is one server-side `if-shell` conditional: it rechecks the pane * generation (`pane_id`), process, liveness and mode, then pastes the pre-loaded - * buffer, sends the submit key, and prints an acknowledgement — or takes the + * buffer, sends the submit key and prints an acknowledgement — or takes the * decline branch — in a single command with no suspension between the recheck and * the paste. Its outcome is read from the acknowledgement: the marker means * pasted, the decline marker means declined, a failed command or a missing @@ -305,10 +126,12 @@ export function paneProbeOver(run: TmuxCommand, target: string, activity: PaneAc /** * A live activity source backed by the server's control-mode event stream. * - * It attaches one no-output control client and counts the `%output` and - * `%client-*` events the server reports, so the probe reads real output and - * visible-client generations rather than `history_size` or a timestamp. The - * client is this scope's and is torn down with it. + * Evidence that the boundary reads real output and visible-client generations + * rather than `history_size` or a timestamp: it attaches one no-output control + * client and counts the `%output` and `%client-*` events the server reports. The + * client is this scope's and is torn down with it. Unused by the deterministic + * suite (which supplies a fake activity source) and never reached by a live + * journey after the VIEW_ONLY closeout. */ export function useControlFeed( socket: string, @@ -351,576 +174,3 @@ function commandHash(command: string): number { } return hash; } - -/** One role's native identity, read from the isolated launch journal. */ -export interface LaunchIdentity { - readonly provider: LiveProvider; - readonly id: string; -} - -/** - * Read each role's exact native identity from the isolated launch journal. - * - * The journal is XMD's own diagnostic record, not a provider file. Identity is - * taken from the `agent_session_launch` records it retained, never inferred from - * which provider file was touched most recently. - */ -export function readLaunchIdentities(journalPath: string): Operation> { - return (function* (): Operation> { - const identities = new Map(); - if (!(yield* exists(journalPath))) { - return identities; - } - const text = yield* readTextFile(journalPath); - for (const raw of text.split("\n")) { - if (raw.trim().length === 0) { - continue; - } - let event: unknown; - try { - event = JSON.parse(raw); - } catch { - continue; - } - const record = launchRecord(event); - if (record !== undefined) { - identities.set(record.agent, { provider: record.provider, id: record.id }); - } - } - return identities; - })(); -} - -/** One `agent_session_launch` prepared record, or nothing. */ -function launchRecord( - event: unknown, -): { agent: string; provider: LiveProvider; id: string } | undefined { - if (!isRecord(event) || event.type !== "yield" || !isRecord(event.description)) { - return undefined; - } - if (event.description.type !== "agent_session_launch") { - return undefined; - } - const result = event.result; - if (!isRecord(result) || result.status !== "ok" || !isRecord(result.value)) { - return undefined; - } - const value = result.value; - const agent = value.agent; - const id = value.nativeSessionId; - if (typeof agent !== "string" || typeof id !== "string" || id.length === 0) { - return undefined; - } - if (agent !== "claude" && agent !== "codex") { - return undefined; - } - return { agent, provider: agent, id }; -} - -/** The provider parser, session directory and project for one live provider. */ -function providerObserver(provider: LiveProvider, roots: Roots): ObserverSource { - if (provider === "claude") { - // Claude keys a project by its resolved cwd under its configuration, so the - // directory is the project scope and the file name is the identity. - const encoded = roots.project.replaceAll("/", "-"); - return { - parser: claudeParser, - directory: join(roots.env.CLAUDE_CONFIG_DIR, "projects", encoded), - project: roots.project, - }; - } - // Codex shares one sessions root across projects, so the project constrains the - // match through the `cwd` its `session_meta` declares. - return { - parser: codexParser, - directory: join(roots.env.CODEX_HOME, "sessions"), - project: roots.project, - }; -} - -/** How long the journey waits for a live fact before calling it a hang. */ -const SOCKET_DEADLINE_MS = 60_000; -const IDENTITY_DEADLINE_MS = 120_000; -const ACCEPT_DEADLINE_MS = 180_000; -const POLL_MS = 500; -const CHILD_TEARDOWN_MS = 8_000; - -/** - * Run one authorized live journey and return its report. - * - * Reached only past the supervisor's gate, and unexercised until an authorized - * run supplies the turns it spends. It launches the grid, discovers its private - * socket, reads the target role's exact native identity from the isolated - * journal, and drives one uniquely-marked message through the same controller the - * offline suite proves, over the real tmux probe. A deadline only diagnoses a - * hang; acceptance is the provider's exact user event and nothing else. Any setup - * the environment cannot supply is reported `HARNESS_FAILED`, so a review never - * mistakes it for a product finding. - */ -export function runLiveJourney( - provider: LiveProvider, - _env: Record, - base: string, -): Operation { - return (function* (): Operation { - const mode: ReportMode = provider === "claude" ? "live-claude" : "live-codex"; - if (!(yield* exists(XMD_BINARY))) { - return harnessFailed(mode, base, "dist/xmd is not built; run deno task build"); - } - const head = yield* currentHead(base); - - // Evidence the journey fills in; cleanup is read only after the scope below - // has torn down, so a cleanup field is never true before teardown settled. - const counters: { -readonly [K in keyof ReportCounters]: ReportCounters[K] } = zeroCounters(); - const deliveries: DeliveryEvidence[] = []; - const restart = { - queuedRestored: 0, - uncertainAfterRestart: 0, - completedRestored: 0, - reExecutions: 0, - }; - const outcome = { - ran: false, - accepted: false, - completed: false, - identity: "", - sourceHash: "", - version: "", - supportsCompletion: provider === "claude" ? claudeParser.supportsCompletion : true, - materializationTurns: 0, - nativeTurns: 0, - harnessDetail: "", - }; - let providerSessionRemoved = false; - let rootPath = ""; - - try { - yield* scoped(function* (): Operation { - const roots = yield* useIsolatedRoots(); - rootPath = roots.root; - const store = yield* useReplStore(roots.storeDir); - - const closed = withResolvers(); - let didClose = false; - let child: ReturnType | undefined; - const onClose = (): void => { - didClose = true; - closed.resolve(); - }; - // Cleanup registered before the child exists; SIGKILL is followed by a - // mandatory close proof — a child that will not close is a teardown - // failure, never a proved success. - yield* ensure(function* () { - const running = child; - if (running === undefined) { - return; - } - try { - if (!didClose && running.pid !== undefined) { - running.kill("SIGINT"); - yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); - if (!didClose && running.pid !== undefined) { - running.kill("SIGKILL"); - yield* race([closed.operation, sleep(CHILD_TEARDOWN_MS)]); - } - } - if (!didClose) { - throw new Error("the launched grid child could not be proved closed at teardown"); - } - } finally { - running.off("close", onClose); - } - }); - child = spawnChild( - "/usr/bin/script", - [ - "-q", - "/dev/null", - XMD_BINARY, - "run", - liveDocument(provider), - "--journal", - roots.journal, - "--raw", - ], - { cwd: roots.project, env: roots.env, stdio: "ignore" }, - ); - child.on("close", onClose); - - const socket = yield* waitFor(SOCKET_DEADLINE_MS, () => discoverGridSocket(roots.tmp)); - if (socket === undefined) { - outcome.harnessDetail = "the grid's tmux socket never appeared under the owned TMPDIR"; - return; - } - const identity = yield* waitFor(IDENTITY_DEADLINE_MS, function* () { - const identities = yield* readLaunchIdentities(roots.journal); - return [...identities.values()].find((entry) => entry.provider === provider); - }); - if (identity === undefined) { - outcome.harnessDetail = - "the launch journal never retained this provider's native identity"; - return; - } - outcome.ran = true; - outcome.identity = identity.id; - - // Clean the exact provider session through the provider's own supported - // operation, and only that — never a direct transcript delete or a sweep. - yield* ensure(function* () { - const removal = - provider === "claude" - ? yield* runCommand("claude", ["project", "purge", "--yes", roots.project], roots.env) - : yield* runCommand("codex", ["delete", "--force", identity.id], roots.env); - providerSessionRemoved = removal; - }); - - const key = identity.id; - const target = "xmd:0.0"; - const run: TmuxCommand = (args) => tmuxExec(socket, args, roots.env); - const feed = yield* useControlFeed(socket, roots.env); - const probe = paneProbeOver(run, target, feed); - const observer: ObserverSource = providerObserver(provider, roots); - outcome.supportsCompletion = observer.parser.supportsCompletion; - const options: DeliveryOptions = { - messageDir: roots.messageDir, - bracketedPaste: true, - submitKey: "Enter", - }; - const first = yield* probe.snapshot(); - - yield* store.dispatch({ type: "ReplOpened", replSession: `live-${provider}` }); - yield* store.dispatch({ - type: "RoleBound", - key, - role: provider === "claude" ? "Implementor" : "Reviewer", - issue: "#774", - identity, - paneGeneration: first.generation, - }); - const marker = `MK-${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; - const messageId = `msg-${randomUUID().slice(0, 8)}`; - const text = `Reply with exactly this token on its own line and nothing else: ${marker}`; - yield* store.dispatch({ type: "MessageQueued", key, id: messageId, text, marker }); - - const admitted = yield* driveUntilPasted( - store, - key, - probe, - observer, - options, - counters, - deliveries, - ); - if (admitted) { - outcome.nativeTurns = 1; - const deadline = Date.now() + ACCEPT_DEADLINE_MS; - while (Date.now() < deadline) { - yield* observeStep(store, key, observer); - const role = store.state().roles[key]; - const message = role?.messages.find((entry) => entry.id === messageId); - outcome.accepted = message?.state === "accepted" || message?.state === "completed"; - outcome.completed = message?.state === "completed"; - if (outcome.completed) { - break; - } - yield* sleep(POLL_MS); - } - if (!outcome.accepted) { - yield* settleUnconfirmed(store, key, "no-acceptance-before-deadline"); - counters.uncertain += 1; - } - } - - // The exact located source's file identity, hashed for the report. - const located = yield* locate(observer.parser, observer.directory, key, observer.project); - if (located.outcome === "located") { - outcome.sourceHash = identityHash(located.source.fileKey); - } - // The materialization turn XMD spent, counted from the journal, not guessed. - outcome.materializationTurns = yield* materializationTurns(roots.journal, key); - outcome.version = yield* providerVersion(provider, roots.env); - - // A live restart proof: a fresh store over the same log must restore the - // outcome and re-execute nothing. - const restarted = yield* useReplStore(roots.storeDir); - const settled = yield* reconcileRestart(restarted); - restart.uncertainAfterRestart = settled; - const restoredMessage = restarted - .state() - .roles[key]?.messages.find((m) => m.id === messageId); - if (restoredMessage?.state === "completed") { - restart.completedRestored = 1; - } else if (restoredMessage?.state === "queued") { - restart.queuedRestored = 1; - } - const replay = yield* attemptStep(restarted, key, probe, observer, options); - if (replay.outcome === "pasted") { - // Re-execution after restart is a defect the report must surface. - restart.reExecutions = 1; - } - }); - } catch (error) { - outcome.harnessDetail = classifyError(error); - } - - // Read only now, after the scope's finalizers ran: the root is removed, its - // message directory with it, and the provider session was cleaned. - const rootGone = rootPath === "" ? true : !(yield* exists(rootPath)); - const cleanup: CleanupEvidence & { providerSessionRemoved: boolean } = { - storeRemoved: rootGone, - messageFilesRemoved: rootGone, - providerFilesUntouched: true, - providerSessionRemoved, - }; - - if (outcome.harnessDetail.length > 0 && !outcome.ran) { - return harnessFailed(mode, base, outcome.harnessDetail); - } - return liveReport({ - provider, - mode, - base, - head, - identity: outcome.identity, - sourceHash: outcome.sourceHash, - version: outcome.version, - accepted: outcome.accepted, - completed: outcome.completed, - supportsCompletion: outcome.supportsCompletion, - materializationTurns: outcome.materializationTurns, - nativeTurns: outcome.nativeTurns, - counters, - deliveries, - restart, - cleanup: { - storeRemoved: cleanup.storeRemoved, - messageFilesRemoved: cleanup.messageFilesRemoved, - providerFilesUntouched: cleanup.providerFilesUntouched, - }, - }); - })(); -} - -/** The current commit, for the report's head, or the base when git is unavailable. */ -function currentHead(base: string): Operation<{ sha: string }> { - return (function* (): Operation<{ sha: string }> { - try { - const result = yield* exec("git", { arguments: ["rev-parse", "HEAD"] }).join(); - const sha = result.stdout.trim(); - return { sha: result.code === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : base }; - } catch { - return { sha: base }; - } - })(); -} - -/** Run one provider cleanup command and report whether it succeeded. */ -function runCommand( - command: string, - args: readonly string[], - env: Record, -): Operation { - return (function* (): Operation { - try { - const result = yield* exec(command, { arguments: [...args], env }).join(); - return result.code === 0; - } catch { - return false; - } - })(); -} - -/** Count the materialization turns the launch journal retained for one session. */ -function materializationTurns(journalPath: string, identity: string): Operation { - return (function* (): Operation { - if (!(yield* exists(journalPath))) { - return 0; - } - const text = yield* readTextFile(journalPath); - let count = 0; - for (const raw of text.split("\n")) { - if (raw.trim().length === 0) { - continue; - } - let event: unknown; - try { - event = JSON.parse(raw); - } catch { - continue; - } - if (!isRecord(event) || !isRecord(event.result) || !isRecord(event.result.value)) { - continue; - } - const value = event.result.value; - if (value.phase === "materialized" && value.nativeSessionId === identity) { - count += 1; - } - } - return count; - })(); -} - -/** A fixed-category description of a harness error, carrying no private detail. */ -function classifyError(error: unknown): string { - return error instanceof Error ? error.name : "unknown-harness-error"; -} - -/** The provider's reported version, or "" when it will not say. */ -function providerVersion(provider: LiveProvider, env: Record): Operation { - return (function* (): Operation { - const result = yield* exec(provider, { arguments: ["--version"], env }).join(); - return result.code === 0 ? result.stdout.trim() : ""; - })(); -} - -/** Poll `probe` until it yields a value or the deadline is spent. */ -function waitFor( - deadlineMs: number, - probe: () => Operation, -): Operation { - return (function* (): Operation { - const deadline = Date.now() + deadlineMs; - while (Date.now() < deadline) { - const value = yield* probe(); - if (value !== undefined) { - return value; - } - yield* sleep(POLL_MS); - } - return undefined; - })(); -} - -/** Attempt delivery until one paste is admitted, or a deadline gives up. */ -function driveUntilPasted( - store: ReplStore, - key: string, - probe: PaneProbe, - observer: ObserverSource, - options: DeliveryOptions, - counters: { -readonly [K in keyof ReportCounters]: ReportCounters[K] }, - deliveries: DeliveryEvidence[], -): Operation { - return (function* (): Operation { - const deadline = Date.now() + ACCEPT_DEADLINE_MS; - while (Date.now() < deadline) { - yield* observeStep(store, key, observer); - const attempt = yield* attemptStep(store, key, probe, observer, options); - counters.convergenceAttempts += 1; - if (attempt.outcome === "pasted") { - counters.admittedDeliveries += 1; - deliveries.push({ messageHash: attempt.hash, byteCount: attempt.byteCount }); - return true; - } - if (attempt.outcome === "refused") { - counters.refusals += 1; - } - yield* sleep(POLL_MS); - } - return false; - })(); -} - -/** - * Build the report for one single-provider live journey. - * - * The overall verdict is scoped to the one provider this journey launched: - * `PASS` when the exact bytes were accepted and the turn completed with no - * unsafe admission; `PROVIDER_EXCLUDED` when Claude accepted but produced no - * unambiguous completion record (its interactive format's completion boundary is - * unproven in this POC); otherwise `VIEW_ONLY`. The full POC decision is the - * conjunction of the offline matrix and both provider documents passing in their - * own authorized runs — no single artifact claims both providers. - */ -function liveReport(inputs: { - provider: LiveProvider; - mode: ReportMode; - base: string; - head: { sha: string }; - identity: string; - sourceHash: string; - version: string; - accepted: boolean; - completed: boolean; - supportsCompletion: boolean; - materializationTurns: number; - nativeTurns: number; - counters: ReportCounters; - deliveries: readonly DeliveryEvidence[]; - restart: RestartEvidence; - cleanup: CleanupEvidence; -}): TerminalReplReport { - const safe = countersSafe(inputs.counters); - const providerVerdict: ProviderVerdict = decideProviderVerdict({ - accepted: inputs.accepted, - completed: inputs.completed, - safe, - supportsCompletion: inputs.supportsCompletion, - }); - // Codex spends a materialization turn plus the marker turn; Claude spends only - // the marker turn. Both are counted from evidence, never assumed. - const spent = - inputs.provider === "codex" - ? inputs.materializationTurns + inputs.nativeTurns - : inputs.nativeTurns; - const provider: ProviderReport = { - verdict: providerVerdict, - versionKnown: inputs.version.length > 0, - ...(inputs.version.length > 0 ? { version: inputs.version } : {}), - identityHash: identityHash(inputs.identity), - ...(inputs.sourceHash.length > 0 ? { sourceIdentityHash: inputs.sourceHash } : {}), - accepted: inputs.accepted, - completed: inputs.completed, - }; - const absent: ProviderReport = { verdict: "n/a", versionKnown: false }; - // The single-provider journey's overall verdict is its provider verdict, which - // is never "n/a" here (this provider ran). - const overall: ReportVerdict = providerVerdict === "n/a" ? "VIEW_ONLY" : providerVerdict; - return { - schema: "terminal-repl-poc-report.v1", - verdict: overall, - mode: inputs.mode, - runtime: "deno", - base: { sha: inputs.base }, - head: inputs.head, - providers: { - claude: inputs.provider === "claude" ? provider : absent, - codex: inputs.provider === "codex" ? provider : absent, - }, - turnBudgets: { - claudeAuthorized: inputs.provider === "claude" ? 1 : 0, - claudeSpent: inputs.provider === "claude" ? spent : 0, - codexAuthorized: inputs.provider === "codex" ? 2 : 0, - codexSpent: inputs.provider === "codex" ? spent : 0, - }, - matrix: [], - counters: inputs.counters, - deliveries: [...inputs.deliveries], - restart: inputs.restart, - cleanup: inputs.cleanup, - }; -} - -/** A report for a live journey the environment or harness could not complete. */ -function harnessFailed(mode: ReportMode, base: string, detail: string): TerminalReplReport { - const runtime = "deno"; - const provider = { verdict: "n/a" as const, versionKnown: false }; - return { - schema: "terminal-repl-poc-report.v1", - verdict: "HARNESS_FAILED", - mode, - runtime, - detail, - base: { sha: base }, - providers: { claude: provider, codex: provider }, - turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, - matrix: [], - counters: zeroCounters(), - restart: { queuedRestored: 0, uncertainAfterRestart: 0, completedRestored: 0, reExecutions: 0 }, - cleanup: { storeRemoved: true, messageFilesRemoved: true, providerFilesUntouched: true }, - }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/terminal-tmux/tests/repl-poc.test.ts b/packages/terminal-tmux/tests/repl-poc.test.ts index 8ff9172aa..442ca2d48 100644 --- a/packages/terminal-tmux/tests/repl-poc.test.ts +++ b/packages/terminal-tmux/tests/repl-poc.test.ts @@ -40,7 +40,7 @@ import { purgeStore, ReplStoreError, useReplStore } from "../poc/repl/store.ts"; import type { ReplStore } from "../poc/repl/store.ts"; import { claudeParser, createClaudeParser } from "../poc/repl/claude-observer.ts"; import { codexParser } from "../poc/repl/codex-observer.ts"; -import { gatesSatisfied, runLiveProof } from "../poc/repl/live-supervisor.ts"; +import { runLiveProof, viewOnlyCloseout } from "../poc/repl/live-supervisor.ts"; import { paneProbeOver } from "../poc/repl/live-worker.ts"; import type { PaneActivity, TmuxCommand } from "../poc/repl/live-worker.ts"; import type { Provider, ReplState } from "../poc/repl/state.ts"; @@ -1146,38 +1146,23 @@ describe("issue #774 — black-box REPL messaging POC", () => { expect(strayAttributed).toEqual(false); }); - it("per-provider authorization does not launch the other provider", function* () { - // Only Codex gates are set; a Claude proof still refuses without launching. - const codexOnly = { - XMD_TERMINAL_REPL_CODEX_PROOF: "1", - XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED: "2", - }; - expect(gatesSatisfied("codex", codexOnly)).toEqual(true); - expect(gatesSatisfied("claude", codexOnly)).toEqual(false); - const claudeReport = yield* runLiveProof("claude", codexOnly, BASE_SHA); - expect(claudeReport.verdict).toEqual("NOT_AUTHORIZED"); - expect(claudeReport.turnBudgets.claudeSpent).toEqual(0); - expect(claudeReport.turnBudgets.codexSpent).toEqual(0); - - const claudeOnly = { + it("the live delivery journey is permanently disabled at the VIEW_ONLY closeout", function* () { + // Under any environment — including both gates set — the supervisor launches + // nothing and returns the VIEW_ONLY conclusion. Reliable dispatch stays + // ACP-owned; there is no authorization that runs a delivery. + const armed = { XMD_TERMINAL_REPL_CLAUDE_PROOF: "1", XMD_TERMINAL_REPL_CLAUDE_MODEL_TURNS_AUTHORIZED: "1", - }; - expect(gatesSatisfied("claude", claudeOnly)).toEqual(true); - expect(gatesSatisfied("codex", claudeOnly)).toEqual(false); - const codexReport = yield* runLiveProof("codex", claudeOnly, BASE_SHA); - expect(codexReport.verdict).toEqual("NOT_AUTHORIZED"); - expect(codexReport.turnBudgets.codexSpent).toEqual(0); - }); - - it("a wrong turn value gate refuses: previous authorization does not count", function* () { - const wrongTurns = { XMD_TERMINAL_REPL_CODEX_PROOF: "1", - XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED: "1", + XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED: "2", }; - expect(gatesSatisfied("codex", wrongTurns)).toEqual(false); - const report = yield* runLiveProof("codex", wrongTurns, BASE_SHA); - expect(report.verdict).toEqual("NOT_AUTHORIZED"); + for (const provider of ["claude", "codex"] as const) { + const report = yield* runLiveProof(provider, armed, BASE_SHA); + expect(report.verdict).toEqual("VIEW_ONLY"); + expect(report.turnBudgets.claudeSpent).toEqual(0); + expect(report.turnBudgets.codexSpent).toEqual(0); + expect((yield* validateReport(report)).valid).toEqual(true); + } }); it("a provider turn opening while the buffer loads refuses before the guard", function* () { @@ -1731,41 +1716,32 @@ describe("issue #774 — black-box REPL messaging POC", () => { const forbiddenField = { ...claudePass, secret: "leak" }; expect((yield* validateReport(forbiddenField)).valid).toEqual(false); }); - it("produces a valid terminal-repl-poc-report.v1 with every RP row passing", function* () { + it("records the VIEW_ONLY conclusion in a valid overall report", function* () { const passed = matrix.filter((entry) => entry.result === "pass").length; expect(matrix.length).toEqual(18); expect(passed).toEqual(18); - expect(tally.duplicateDeliveries).toEqual(0); - expect(tally.wrongPaneDeliveries).toEqual(0); - expect(tally.busyAdmissions).toEqual(0); - expect(tally.manualActivityAdmissions).toEqual(0); - - // The offline suite cannot spend a live turn, so its own report is the - // honest NOT_AUTHORIZED shape rather than an overall PASS — which the schema - // reserves for a run that also completed both authorized live journeys. - const offline: TerminalReplReport = { - schema: REPORT_SCHEMA, - verdict: "NOT_AUTHORIZED", - mode: "deterministic", - runtime: runtimeName(), - detail: "offline deterministic matrix; live journeys require authorization", - base: { sha: BASE_SHA }, - providers: { - claude: { verdict: "NOT_AUTHORIZED", versionKnown: false }, - codex: { verdict: "NOT_AUTHORIZED", versionKnown: false }, - }, - turnBudgets: { claudeAuthorized: 0, claudeSpent: 0, codexAuthorized: 0, codexSpent: 0 }, - matrix, - counters: tally, - deliveries, - restart, - cleanup, - }; - const validation = yield* validateReport(offline); + expect(countersSafe(tally)).toEqual(true); + + // The POC's decision is VIEW_ONLY: the offline matrix passed, but reliable + // dispatch is not established, so neither provider's live journey passes and + // the overall verdict is VIEW_ONLY rather than PASS. + const overall = aggregateReport( + { sha: BASE_SHA }, + { sha: HEAD_SHA }, + runtimeName(), + { matrix, counters: tally, restart, cleanup, deliveries }, + viewOnlyCloseout("claude", BASE_SHA), + viewOnlyCloseout("codex", BASE_SHA), + ); + expect(overall.verdict).toEqual("VIEW_ONLY"); + expect(overall.mode).toEqual("overall"); + const validation = yield* validateReport(overall); if (!validation.valid) { throw new Error(`report failed schema validation: ${validation.errors.join("; ")}`); } expect(validation.valid).toEqual(true); + // The exact race is recorded in the closeout detail. + expect(viewOnlyCloseout("claude", BASE_SHA).detail?.includes("VIEW_ONLY")).toEqual(true); }); });