diff --git a/packages/terminal-tmux/poc/repl/README.md b/packages/terminal-tmux/poc/repl/README.md new file mode 100644 index 000000000..7954013ee --- /dev/null +++ b/packages/terminal-tmux/poc/repl/README.md @@ -0,0 +1,62 @@ +# 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 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`. It +adds no Workflow capability, no journal record, no replay rule, and no workflow +syntax, and it changes no production, architecture or specification file. + +## What the evidence establishes + +- `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 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, 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, 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. + +## The live journey is closed out + +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. + +## Running the evidence + +```sh +deno task test packages/terminal-tmux/tests/repl-poc.test.ts +``` + +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/actions.ts b/packages/terminal-tmux/poc/repl/actions.ts new file mode 100644 index 000000000..cd72ae65d --- /dev/null +++ b/packages/terminal-tmux/poc/repl/actions.ts @@ -0,0 +1,206 @@ +/** + * 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; + /** The provider turn this acceptance belongs to, when grouped by turn. */ + readonly turn?: 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; + /** The provider turn this output belongs to, when grouped by turn. */ + readonly turn?: 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 provider turn this completion closes, when grouped by turn. */ + readonly turn?: 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..42c0cd1a7 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/claude-observer.ts @@ -0,0 +1,122 @@ +/** + * 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, 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. + * + * 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"; + +/** + * 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 = createClaudeParser(true); + +/** 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", +): 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` }; + } + 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 }; + } + return { kind: "assistant-output", identity, text, turn }; +} + +/** 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" }; + } + 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. */ +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..740703c93 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/codex-observer.ts @@ -0,0 +1,92 @@ +/** + * Issue #774 POC — the Codex rollout-file parser. + * + * Codex maps a thread identity to one rollout `.jsonl` file. A `session_meta` + * 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. 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 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. + 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 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" }; + } + 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. */ +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..a0e8d8b04 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/controller.ts @@ -0,0 +1,479 @@ +/** + * 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. + * 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 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, 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. + * + * Terminal convergence only ever authorizes an attempt. Acceptance and + * completion come from the provider file. + */ + +import type { Operation } from "effection"; +import { converge, providerUnchanged, structurallyEqual } from "./convergence.ts"; +import type { PaneProbe, SampleResult } from "./convergence.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"; +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; + /** 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. */ +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: "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 }; + +/** What one observation step did. */ +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 + ? 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"); +} + +/** 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 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 }; + +/** 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 }; + } + // The physical size, including a partial tail no cursor covers, so a record + // being written is visible before it parses as a complete event. + const physicalSize = yield* physicalSizeOf(located.source.path); + return { + outcome: "read", + openTurn: hasOpenTurn(readOut.events), + cursor: readOut.cursor, + eventCount: readOut.events.length, + physicalSize, + }; + })(); +} + +/** 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, + physicalSize: provider.physicalSize, + }, + }, + }; + }; +} + +/** + * Try to deliver one role's queue head. + * + * The order is the contract: prove the pane usable and the provider idle and + * unchanged across a barrier; prepare the private file and buffer; take one final + * combined sample after that preparation; record the intent; then paste under a + * single guarded operation. Anything unproved before the intent leaves the + * message queued; a paste that cannot be fully proved leaves it uncertain. + */ +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" }; + } + + // 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 sampler = makeSampler(probe, observer, role.identity.id); + const converged = yield* converge(sampler, () => probe.barrier()); + if (converged.outcome === "not-ready") { + const refusal = asRefusal(converged.reason); + 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 }); + } + 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.pane.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" }); + + // 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, + 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, + }; + }, + ); + })(); +} + +/** 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 — 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, + 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, + observer.project, + ); + 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 and turn. */ +function applyEvent(store: ReplStore, key: string, event: NormalizedEvent): Operation { + return (function* (): Operation { + const role = store.state().roles[key]; + 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. + 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, + ...(event.turn === undefined ? {} : { turn: event.turn }), + }); + } + return; + } + const accepted = acceptedMessage(role); + 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", + key, + eventKey: event.key, + identity: event.identity, + text: event.text, + ...(event.turn === undefined ? {} : { turn: event.turn }), + }); + return; + } + yield* store.dispatch({ + type: "AssistantCompleted", + key, + id: accepted.id, + eventKey: event.key, + identity: event.identity, + ...(event.turn === undefined ? {} : { turn: event.turn }), + }); + })(); +} + +/** + * 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; + })(); +} diff --git a/packages/terminal-tmux/poc/repl/convergence.ts b/packages/terminal-tmux/poc/repl/convergence.ts new file mode 100644 index 000000000..be20ad83f --- /dev/null +++ b/packages/terminal-tmux/poc/repl/convergence.ts @@ -0,0 +1,213 @@ +/** + * 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 — and the provider's own + * open-turn/cursor/event state from its session file. It never reads prompt + * wording or screen text. + * + * 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 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. + */ + +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; +} + +/** 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; + /** + * 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. */ +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. */ + 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. + * + * `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: "uncertain"; readonly reason: string }; + +/** + * The pane operations convergence and delivery need. + * + * 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 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, 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: ConvergenceSample } + | { readonly outcome: "not-ready"; readonly reason: string }; + +/** + * Attempt to converge one pane to a safe input point. + * + * 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( + sample: () => Operation, + barrier: () => Operation, +): Operation { + return (function* (): Operation { + 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 usable = usability(first.sample.pane); + if (usable !== undefined) { + return { outcome: "not-ready", reason: usable }; + } + 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.sample.pane.epoch !== first.sample.pane.epoch) { + return { outcome: "not-ready", reason: "intervening-event" }; + } + if (!providerUnchanged(first.sample.provider, second.sample.provider)) { + return { outcome: "not-ready", reason: "provider-event" }; + } + return { outcome: "converged", guard: second.sample }; + })(); +} + +/** 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 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 && + left.physicalSize === right.physicalSize + ); +} + +/** 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..4c6289b91 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/delivery.ts @@ -0,0 +1,125 @@ +/** + * 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. + * + * 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: 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"; +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 { PaneProbe, PaneSnapshot } from "./convergence.ts"; + +/** 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; + /** 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; +} + +/** 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 } + | { 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}`; +} + +/** + * Prepare one message's private file and tmux buffer, then run `body`. + * + * 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 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 + // acquiring and registering cannot leave the file or the buffer behind. + yield* ensure(() => probe.deleteBuffer(buffer)); + yield* ensure(() => rm(path, { force: true })); + + yield* writeTextFile(path, request.bytes); + yield* until(chmod(path, 0o600)); + yield* probe.loadBuffer(buffer, path); + + const byteCount = new TextEncoder().encode(request.bytes).length; + const hash = hashBytes(request.bytes); + const prepared: PreparedDelivery = { + byteCount, + hash, + *paste(guard: PaneSnapshot): 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); + }); +} + +/** 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..1a0518568 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/live-supervisor.ts @@ -0,0 +1,125 @@ +/** + * Issue #774 POC — the closed-out live supervisor. + * + * 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 { validateReport } from "./report.ts"; +import type { ReportMode, TerminalReplReport } from "./report.ts"; + +/** The provider a (now disabled) live journey would have targeted. */ +export type LiveProvider = "claude" | "codex"; + +/** The base commit this POC was built from, recorded in every report. */ +const BASE_SHA = "97fda6aa7b5f85db747c066898fd3ef3c6d1dbeb"; + +/** 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 { + const globals = globalThis as { Deno?: unknown; Bun?: unknown }; + if (globals.Deno !== undefined) { + return "deno"; + } + if (globals.Bun !== undefined) { + return "bun"; + } + 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 }, + }; +} + +/** + * The live proof, permanently disabled. + * + * 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, + base: string, +): Operation { + return (function* (): Operation { + return viewOnlyCloseout(provider, base); + })(); +} + +/** 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 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)); + if (provider === undefined) { + process.stdout.write( + `${JSON.stringify({ error: "usage: live-supervisor.ts " })}\n`, + ); + return; + } + const report = yield* runLiveProof(provider, process.env, BASE_SHA); + 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 new file mode 100644 index 000000000..51949bf0d --- /dev/null +++ b/packages/terminal-tmux/poc/repl/live-worker.ts @@ -0,0 +1,176 @@ +/** + * Issue #774 POC — the terminal-boundary evidence, after the VIEW_ONLY closeout. + * + * 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 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 { exec } from "@effectionx/process"; +import { lines } from "@effectionx/stream-helpers"; +import { resource, spawn } from "effection"; +import type { Operation } from "effection"; +import type { GuardOutcome, PaneProbe, PaneSnapshot, PasteRequest } from "./convergence.ts"; + +/** + * One tmux command run against a server, and its acknowledged result. + * + * 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 }>; + +/** + * 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. + */ +export interface PaneActivity { + read(): Operation<{ outputEvents: number; clientActivity: number }>; +} + +/** + * A `PaneProbe` over an injectable tmux command seam and activity source. + * + * `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 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}"; + 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 generations = yield* activity.read(); + return { + // `%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: generations.clientActivity, + outputEvents: generations.outputEvents, + epoch: generations.outputEvents + generations.clientActivity, + }; + })(); + } + + return { + snapshot: readSnapshot, + *barrier(): Operation { + // An acknowledged round-trip that changes nothing by itself. + yield* run(["display", "-p", "-t", target, "barrier"]); + }, + *loadBuffer(buffer, path): Operation { + yield* run(["load-buffer", "-b", buffer, path]); + }, + *deleteBuffer(buffer): Operation { + yield* run(["delete-buffer", "-b", buffer]); + }, + *guardedPaste(guard: PaneSnapshot, delivery: PasteRequest): Operation { + 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 (result.stdout.includes(nonce)) { + return { outcome: "pasted" }; + } + if (result.stdout.includes("DECLINED")) { + return { outcome: "declined", reason: "guard-rejected" }; + } + // 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. + * + * 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, + 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; + for (const character of command) { + hash = (hash * 31 + character.charCodeAt(0)) % 1_000_000_007; + } + return hash; +} diff --git a/packages/terminal-tmux/poc/repl/observer.ts b/packages/terminal-tmux/poc/repl/observer.ts new file mode 100644 index 000000000..a4e235ab0 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/observer.ts @@ -0,0 +1,411 @@ +/** + * 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 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. + * - 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. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; +import { open, 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; + /** + * 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. */ + 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. `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 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 and project. */ +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 matching `expected` identity and `expectedProject`, exactly. + * + * 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[]; + 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); + // 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) }); + } + } + 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 `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 { + // 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 { + text = yield* readPrefix(path, HEADER_PREFIX_BYTES); + } catch { + return false; + } + // 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; + } + let record: unknown; + try { + record = JSON.parse(line); + } catch { + return false; + } + if (!isRecord(record)) { + return false; + } + const parsed = parser.classify(record); + 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; + } + // 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; + } + 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, + ...(parsed.turn === undefined ? {} : { turn: parsed.turn }), + }, + }; + case "turn-completed": + if (!identityMatches(parsed.identity, identity)) { + return { outcome: "refused", refusal: "identity-mismatch" }; + } + return { + outcome: "kept", + event: { + kind: "turn-completed", + key, + identity, + text: "", + ...(parsed.turn === undefined ? {} : { turn: parsed.turn }), + }, + }; + } +} + +/** 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 { + 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..d506efa7d --- /dev/null +++ b/packages/terminal-tmux/poc/repl/report.schema.json @@ -0,0 +1,537 @@ +{ + "$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", "overall"] + }, + "runtime": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "base": { + "type": "object", + "additionalProperties": false, + "required": ["sha"], + "properties": { + "sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "parent": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + } + } + }, + "head": { + "type": "object", + "additionalProperties": false, + "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" + } + } + }, + "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" + }, + "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" + } + }, + "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": { + "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" + } + } + } + } + } + }, + { + "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 new file mode 100644 index 000000000..8b4cf2b84 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/report.ts @@ -0,0 +1,325 @@ +/** + * 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 *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"; +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" | "overall"; + +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; + /** 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 { + 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 }, + }; +} + +/** 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 { + 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..758218138 --- /dev/null +++ b/packages/terminal-tmux/poc/repl/state.ts @@ -0,0 +1,292 @@ +/** + * 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; + /** 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. */ +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, + turn: action.turn, + }, + ); + case "AssistantObserved": + return recordEvent(role, { + kind: "assistant-output", + 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: "", + turn: action.turn, + }, + ); + 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..5597c4fce --- /dev/null +++ b/packages/terminal-tmux/poc/repl/store.ts @@ -0,0 +1,347 @@ +/** + * 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. 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, 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 { z } from "zod"; +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 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; + +/** + * 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`; +} + +/** 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. + * + * 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 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); + 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); + let running = emptyState(); + 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}`, + ); + } + 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; +} + +/** 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})`); + } + const parsed = EntrySchema.safeParse(value); + if (!parsed.success) { + throw new ReplStoreError( + `a malformed record (${name}): ${parsed.error.issues[0]?.message ?? "invalid"}`, + ); + } + 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: parsed.data.seq, action: parsed.data.action }; +} + +/** 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 }); +} 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..aaacb5051 --- /dev/null +++ b/packages/terminal-tmux/tests/fixtures/repl-poc/fake-terminal.ts @@ -0,0 +1,349 @@ +/** + * 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. + * + * 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, a + * wrong project 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; + /** 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). */ + 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 (may do async work). */ + armBarrier(mutate: () => Operation): void; + /** Run `mutate` the next time a buffer is loaded (just before the guarded paste). */ + armLoad(mutate: () => Operation): 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. */ +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: (() => Operation) | undefined; + let loadTrap: (() => Operation) | undefined; + let guardFailure: "declined" | "uncertain" | 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) { + yield* 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) { + yield* trap(); + } + const bytes = new TextDecoder().decode(yield* until(readFile(path))); + 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. + 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 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" }; + }, + }; + + return { + probe, + get deliveries() { + return deliveries; + }, + get declines() { + return declines; + }, + pendingBuffers() { + return buffers.size; + }, + 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; + }, + 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, turn?: string): string { + return line({ + type: "user", + sessionId, + ...(turn === undefined ? {} : { requestId: turn }), + message: { role: "user", content: [{ type: "text", text }] }, + }); +} + +/** 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, 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. */ +export function claudeUnsupported(sessionId: string): string { + return line({ type: "user", sessionId, message: { role: "user" } }); +} + +/** 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. */ +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..442ca2d48 --- /dev/null +++ b/packages/terminal-tmux/tests/repl-poc.test.ts @@ -0,0 +1,1795 @@ +/** + * 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, and it models the two ways the final guard can go + * wrong on a real server (a declined command and an unacknowledged submit). + * + * 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. + * + * 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, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { race, spawn, suspend, 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 { 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, createClaudeParser } from "../poc/repl/claude-observer.ts"; +import { codexParser } from "../poc/repl/codex-observer.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"; +import type { ProviderParser } from "../poc/repl/observer.ts"; +import { + aggregateReport, + countersSafe, + decideProviderVerdict, + identityHash, + REPORT_SCHEMA, + validateReport, + zeroCounters, +} from "../poc/repl/report.ts"; +import type { + DeliveryEvidence, + 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"; + +/** 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, 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; +} + +const CLAUDE_KIT: ProviderKit = { + provider: "claude", + parser: claudeParser, + path: (directory, id) => claudeSessionPath(directory, id), + idle: () => [], + 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), +}; + +const CODEX_KIT: ProviderKit = { + provider: "codex", + parser: codexParser, + path: (directory) => codexRolloutPath(directory, "main"), + idle: (id, project) => [codexMeta(id, project)], + 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 project: 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}`); + const project = join(root, `project-${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, + project, + store, + pane, + identity, + observer: { parser: kit.parser, directory: providerDir, project }, + 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, bag.project)); + })(); +} + +/** 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-"); + // 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); + + 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 }); + } + expect(bag.pane.deliveries.length).toEqual(1); + expect(bag.pane.deliveries[0]?.whileBusy).toEqual(false); + expect(bag.pane.deliveries[0]?.whileManual).toEqual(false); + + yield* appendRecords(bag.path, [ + 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"); + } + 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); + + 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"); + + 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; + } + + yield* appendRecords(claude.path, [ + 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), + 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; + } + 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); + + 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; + } + expect(bag.pane.deliveries[0]?.bytes).toEqual(text); + + yield* appendRecords(bag.path, [codexUser(text), codexComplete()]); + yield* observeStep(bag.store, bag.identity.id, bag.observer); + 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); + + 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"); + + 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"); + + 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); + + 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; + } + + yield* observeStep(bag.store, bag.identity.id, bag.observer); + const cursorBefore = role(bag.store.state(), bag.identity.id).cursor; + + 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"); + + 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, 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(); + return until(Promise.resolve()); + }); + const attempt = yield* attemptStep( + bag.store, + bag.identity.id, + bag.pane.probe, + bag.observer, + bag.options, + ); + 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(0); + expect(messageState(bag.store, bag.identity.id, message.id)).toEqual("queued"); + expect( + record( + "RP10", + true, + "same-PID manual activity while the buffer loaded declined the attempt before the guard; 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); + 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 for the same identity and project: now two files match. + const second = codexRolloutPath(bag.providerDir, "duplicate"); + yield* writeRecords(second, [codexMeta(bag.identity.id, bag.project)]); + 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 and project 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); + + 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); + + yield* rotateFile(bag.path, [ + codexMeta(bag.identity.id, bag.project), + 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; + } + 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; + 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; + } + 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"); + + 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-"); + + 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; + } + 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") { + expect(refused.refusal).toEqual("identity-mismatch"); + } + tally.refusals += 1; + expect(messageState(other.store, other.identity.id, otherMessage.id)).toEqual( + "attempt-started", + ); + + 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 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); + 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; + } + // 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); + + // 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); + 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 and buffer removed on success and on a declined partial attempt; provider file byte-identical; store purged; provider file survived", + ), + ).toEqual(true); + }); + + // --- 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("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", + XMD_TERMINAL_REPL_CODEX_PROOF: "1", + XMD_TERMINAL_REPL_CODEX_MODEL_TURNS_AUTHORIZED: "2", + }; + 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* () { + 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 => + (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); + + // 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* () { + 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("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(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); + }); +}); + +/** 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"; +} + +/** 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 }, + }; +}