diff --git a/CONTEXT.md b/CONTEXT.md index cfd874a..45fc7d4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,136 +1,159 @@ # omp-spt — glossary > Authoritative for meaning (grill-with-docs convention). Glossary only — no > implementation detail. Decisions and rationale live in `SCOPE.md` and `docs/adr/`. ## Product and topologies **omp-spt** — the OMP-native harness adapter for spt-core. The repository, registered adapter, and consolidated adapter executable share this identity. _Avoid_: spt-claude-code, claude-spt, omps **OMP** — the Oh My Pi terminal coding agent that omp-spt integrates with. Use **OMP** for the product or UI and `omp` for its executable. _Avoid_: Claude Code, CC **native OMP endpoint** — an spt-hosted endpoint where `omp` owns a broker-held terminal and loads the **OMP SPT extension**. It may run unattended, but its native TUI remains attachable whenever an operator needs to observe or control it. _Avoid_: RPC bridge endpoint, non-interactive endpoint **OMP SPT extension** — the OMP runtime extension that binds a native OMP session to an SPT endpoint and owns message delivery, activity, replies, and lifecycle integration. It does not render or proxy the terminal. _Avoid_: Claude hook, RPC host, terminal bridge **ready OMP endpoint** — a native OMP endpoint that receives SPT messages without a Psyche. _Avoid_: OMP Worker **live OMP endpoint** — a native OMP endpoint whose SPT lifecycle includes a Psyche and durable mind continuity. _Avoid_: OMP Worker, treating every ready endpoint as live **session activation** — binding an ordinary, already-open OMP session as a ready OMP endpoint or live OMP endpoint. Activation establishes the immutable endpoint/session relationship; it does not permit later switching or rebinding. _Avoid_: requiring every endpoint to originate from `spt endpoint run`, in-session endpoint switching **live auto-resume** — an explicit session-activation path that selects the most-recently-active compatible live endpoint when no endpoint id is supplied. Outside that explicit path, activation requires selecting or creating an id and never guesses identity silently. _Avoid_: implicit most-recent selection, deriving identity from the OMP session -**delivery custody** — the interval after an OMP SPT extension accepts an -inbound SPT message and before it either returns a correlated answer or reports -an explicit failure to that sender. A message in custody is never silently -dropped. -_Avoid_: fire-and-forget delivery - -**safe-boundary delivery** — delivery of accepted peer messages into an active -OMP turn at the first proven boundary before the next tool or model -continuation. Messages retain arrival order; if no injectable boundary occurs, -they become ordinary next-turn delivery rather than interrupting the turn. -_Avoid_: arbitrary event injection, forced turn interruption + +**endpoint activity state** — spt-core's published busy/idle record of the +agent's actual harness state. It is routing control, not telemetry: +`--active-only` can enter only through a busy boundary and `--idle-only` can +wake only after an idle transition. The extension keeps it synchronized with +OMP's authoritative activity signal. +_Avoid_: advisory presence, terminal-quiescence inference, eventually-consistent +busy/idle reporting, adapter-side serialization over spt-core's API + + +**delivery-degraded endpoint** — a live endpoint whose OMP work continues while +activity publication or inbound polling is temporarily unavailable. Its cyan +identity remains visible with the warning suffix ` · comms recovering...`; +constrained messages remain in spt-core custody until synchronization recovers. +_Avoid_: replacing endpoint identity with an error, healthy-looking divergence, +stopping local agent work, technical diagnostics in the footer + +**busy delivery** — delivery accepted while an agent turn is active. It joins +that existing turn at its next safe model boundary and never wakes the endpoint +or starts a separate turn. +_Avoid_: active wake, nested turn, busy next-turn delivery + +**idle delivery** — delivery accepted while no agent turn is active. It wakes +the endpoint and opens one ordinary model turn for the accepted message. +_Avoid_: steering an absent turn, treating idle delivery as busy injection + +**safe-boundary delivery** — delivery of a listener event already steered onto +an active OMP turn, or of `api poll` output read at an active context boundary, +before the next tool or model continuation. Listener and poll remain independent +surfaces; the adapter neither batches them nor invents cross-channel ordering. +If a steered listener event finds no injectable boundary, it becomes ordinary +idle delivery after the active turn ends rather than interrupting it. +_Avoid_: synthetic delivery batches, cross-channel chronology, arbitrary event +injection, forced turn interruption **message stub** — the short turn-opening prompt `` used for an inbound peer message while the full SPT event envelope enters the same model turn as extension-provided context. The peer body is never interpreted as an OMP slash command. _Avoid_: typing the peer body directly into the OMP editor **bound OMP session** — the single OMP session owned by a native OMP endpoint for that endpoint's lifetime. Switching or resuming to another OMP session requires stopping and relaunching the endpoint rather than mutating the binding inside the TUI. _Avoid_: treating the endpoint id as a movable session selector **OMP-native checkpoint** — an agent-driven continuity transition that saves the current live context, resets OMP context through native session APIs, and wakes the same endpoint from the saved state without operator intervention. _Avoid_: Claude `/clear` keystroke choreography, save-only checkpoint **durable role** — the endpoint's persistent statement of purpose and optional service description. The OMP-native role skill may inspect it, rewrite it from a directive, or open an interactive draft when no directive is supplied. _Avoid_: transient prompt persona, OMP profile **OMP SPT setup** — the agent-facing readiness flow that diagnoses and completes OMP, spt-core, GitHub transport, omp-spt activation, and requested subnet onboarding from an ordinary OMP session. Operator-only authentication remains an explicit handoff, not a silently skipped setup step. _Avoid_: documentation-only setup, separate plugin bootstrap **startup brief** — concise adapter-authored context supplied when an OMP session activates, teaching identity, roster, messaging, continuity, and lifecycle operations that the session can perform through public `spt` commands. It is guidance, not a parallel command or transport layer. _Avoid_: skill emulation, full CLI reference injection **update notice** — concise agent-facing context emitted when the active spt-core or omp-spt version is known to trail an available compatible release. It identifies the affected component and the public update command. _Avoid_: silent background update, generic startup announcement **commune** — an agent-authored continuity drop that updates a live endpoint's durable mind without ending the endpoint. Its explicit checkpoint mode performs the save step of an OMP-native checkpoint. _Avoid_: signoff, generic project notes **signoff** — an agent-authored final continuity drop followed by graceful endpoint shutdown. The OMP-native signoff skill keeps final context explicit rather than relying solely on automatic echo-commune. _Avoid_: commune, forced endpoint stop **peer-message shortform** — the explicit assistant-output form `@` that dispatches the enclosed message to the named SPT endpoints after the OMP turn. It is a deliberate side effect, not ordinary prose containing an `@` mention. _Avoid_: harness-specific alternate syntax, implicit mention dispatch **targeted hint** — concise adapter-authored context injected when a user turn clearly concerns live activation, identity, messaging, subnet onboarding, or checkpointing. A hint points to the canonical capability without replacing its full instructions. _Avoid_: fuzzy general coaching, full skill-body injection **receivability** — the state in which a bound OMP endpoint can accept and deliver another peer message. Cancellation, interruption, or failed turns must restore receivability automatically after settling affected delivery custody, unless listener recovery itself exhausts and closes the endpoint. _Avoid_: stale busy state, best-effort recovery **continuity drop** — a project-local commune or signoff file written under `.spt/` for spt-core to ingest into an endpoint's durable mind. The directory is harness-neutral; the filename identifies the endpoint and drop kind. diff --git a/DELIVERY-RELIABILITY-PLAN.md b/DELIVERY-RELIABILITY-PLAN.md index 6586665..106d547 100644 --- a/DELIVERY-RELIABILITY-PLAN.md +++ b/DELIVERY-RELIABILITY-PLAN.md @@ -1,20 +1,31 @@ # Delivery reliability plan ## Scope -Fix extension-owned inbound delivery fallback so messages accepted during a turn reliably wake the next OMP turn instead of depending on the editable follow-up queue. +Align omp-spt with spt-core's published busy/idle injection contract without duplicating core custody. Listener events surface immediately through OMP's native message API; active-only traffic surfaces independently through authenticated polling at each active context boundary. Established endpoints keep local OMP work alive while communications recover. -## Open question +## Decisions -Confirm OMP's hidden `nextTurn` custom-message path preserves the full peer envelope in model context without duplicate context injection. +- Declare `hook` injection for activity and idle delivery. +- Treat `before_agent_start` as the busy transition and `ctx.isIdle()` as steady-state truth. +- At every active `context` boundary, publish busy and then run `api poll --include-deferred`; inject non-empty stdout as model-only context. +- Keep listener and poll delivery independent. Do not batch, reorder, persist, or acknowledge above spt-core. +- Retry listener recovery indefinitely with capped backoff. State and poll failures preserve the cyan endpoint identity and append warning-styled ` · comms recovering...`; they never shut down an established OMP session. +- Preserve explicit activation failure before the endpoint binds. +- Use the existing 15-second command deadline. + +## Public-contract limitation + +The published listener and poll surfaces are destructive drains without receipt or acknowledgment operations. omp-spt does not add a second durable spool. A process failure after spt-core emission and before OMP consumption remains a core contract gap. ## Tasks -1. Reproduce the lost-wake behavior at the extension API seam. -2. Route deferred custody through hidden `nextTurn` delivery with `triggerTurn: true`. -3. Update focused tests for full-envelope delivery, ordering, and wake semantics. -4. Run the extension test and traceability gates. +1. Update scope, hazard, ADR, manifest, and requirement wording to the agreed ownership and recovery contract. +2. Refactor extension state publication, health rendering, listener recovery, and context polling. +3. Add focused tests for injection declaration, prompt-start busy publication, active-only polling, model-only poll context, degraded recovery, independent listener delivery, and nonfatal listener exhaustion. +4. Run targeted tests, full deterministic gates, traceability, review, and live endpoint smoke scenarios. +5. Bump the adapter version, build the three release helpers, package `adapter.spt`, publish the GitHub release, update the installed adapter, and smoke-test the published version. ## Gate -`node tests/omp-extension.mjs` and `traceable-reqs check` pass; a live round trip reaches `emphasys` through the extension-owned listener. +`node --test tests/omp-extension.mjs`, `bash ci/run-gates.sh`, `traceable-reqs check`, release artifact validation, and live fresh-endpoint delivery smoke tests all pass. The published adapter version and GitHub release tag agree. diff --git a/OMP-ADAPTER-PLAN.md b/OMP-ADAPTER-PLAN.md index 616eb02..c794fdc 100644 --- a/OMP-ADAPTER-PLAN.md +++ b/OMP-ADAPTER-PLAN.md @@ -1,176 +1,179 @@ # OMP adapter plan Status: **ratified clean cutover** (2026-07-14) `omp-spt` is the SPT harness adapter for [Oh My Pi](https://github.com/can1357/oh-my-pi). The supported product is a native OMP terminal session with SPT messaging and lifecycle behavior supplied by an OMP extension. The inherited alternate-harness plugin, hook/injection stack, RPC bridge, launcher aliases, and provider-wrapper profiles are not compatibility surfaces. ADRs [0008](docs/adr/0008-omp-native-product-boundary.md) through [0013](docs/adr/0013-release-gate-stops-at-the-adapter-boundary.md) are the ratified boundary. ## Product architecture ```text spt-core broker PTY │ └── omp-spt launch-omp │ validates and execs/spawns ▼ native OMP TUI │ loads ▼ packaged omp-spt extension │ ├── bind/listen/state/session-end ├── serialized delivery custody └── correlated reply or explicit failure ``` The process whose interface the operator sees owns the PTY. The launch shim may resolve and validate the real OMP executable, but it must not become a second terminal application. Every hosted endpoint is therefore attachable as the same native OMP TUI whether it is currently attended or unattended. Bare `omp` lookup is not trusted: another executable may own that basename. Resolution must identify a genuine Oh My Pi binary, fail loudly on ambiguity or collision, and preserve OMP's terminal ownership after launch. ## Public SPT contract The adapter uses only the two seams in the published [SPT integration checklist](https://sabermage.github.io/spt-releases/harness-contract/integration-checklist.html): the declarative manifest and imperative `spt api` commands. No private spt-core source, SDK, state schema, or transport implementation is an adapter dependency. ### Register - `[adapter]` identifies `omp-spt`, declares `kind = "harness"`, and advertises only `ReadyAgent` and `LiveAgent`. - `host_binaries = ["omp"]` is the bind-time harness match. The adapter helper binary is intentionally absent so it cannot collide with or impersonate the hosted TUI. - `shortcut_basename = "omp"` brands picker-generated launchers. - The manifest validates against the published schema and then passes the public `spt adapter add` cross-field validation. ### Start and resume - `[session.self]` starts `launch-omp` with the packaged extension. - `[session.resume]` uses the same path plus OMP's native resume argument. - `SPT_ENDPOINT_ID` is broker-injected. Once OMP reports its actual session id, the extension performs the public `api bind --set-session-id ` startup half. - The endpoint/session association is immutable while the endpoint is alive. In-TUI switch, branch, new-session, and resume actions are blocked. Changing sessions requires stopping and relaunching the endpoint with an explicit native-resume target. ### Run -The extension owns a delivery from receipt until exactly one terminal outcome: -a correlated reply to the sender or an explicit failure reply. Deliveries are -serialized so sender and reply custody cannot cross. Submission rejection -advances the queue only after the sender has been told what failed. - +spt-core owns durable delivery custody. Listener events enter OMP immediately +through its native message rail, while active-only messages remain in core until +`api poll --include-deferred` surfaces them at an active model boundary. The +extension does not merge those channels or infer cross-channel chronology. +Assistant output remains local; a peer receives output only through explicit +`spt send` or shortform use. + Each accepted peer message opens an ordinary OMP turn with a short `` user-visible stub. The complete SPT `…` envelope is supplied as context for that same turn. Peer text remains opaque content; a message beginning with an OMP slash command cannot become local operator input. -OMP lifecycle events drive honest `busy`/`idle` state. If the listener exits, -the extension retries with a finite backoff schedule. Exhausting the schedule -ends the SPT session and shuts down the hosted OMP process loudly; a dead -delivery path must never remain advertised as healthy. - +OMP lifecycle events drive honest `busy`/`idle` state. Listener, state, and poll +failures preserve local OMP work after the endpoint is established. The cyan +identity remains visible with ` · comms recovering...`; listener recovery uses +capped indefinite backoff, state recovery converges on current OMP truth, and +polling retries only at a real model boundary. Initial bind failure remains loud. + ### End Normal OMP shutdown causes the extension to release the listener and call the public session-end seam. Listener exhaustion uses the same teardown path before closing OMP. SPT retains spool and history according to its published lifecycle contract. ## ReadyAgent and LiveAgent `omp-spt` supports exactly two endpoint types: - **ReadyAgent** — native OMP plus extension-owned messaging, without a Psyche. - **LiveAgent** — the same native endpoint with the manifest's go-live gate and bounded `[session.psyche_resume]` turns. The Psyche is not a resident second endpoint. Each invocation is one bounded headless OMP turn using the core-written context file and its own private OMP session directory, then exits. OMP subagents are not advertised as independent SPT Workers. ## History, digest, and continuity OMP session JSONL is authoritative: ```text ~/.omp/agent/sessions//_.jsonl ``` - `[history] strategy = "fetcher"` locates the session by id and streams its OMP JSONL for the bounded summarizer. - `[digest] strategy = "fetcher"` maps OMP records to SPT's neutral `{role,text,tool,ts}` NDJSON contract. Delivered user-facing messages remain turn-opening input records so digest cursors retain turn granularity. - `[session.echo_commune]` self-locates history and prints one bounded context delta on stdout; spt-core owns the file drop, ingest, and deletion. Commune and signoff drops live under project-local `.spt/`. The published v0.29.0 echo-commune contract resolves a relative watched directory against the endpoint's recorded working directory, never the daemon cwd, and warns rather than guessing when no endpoint cwd exists. The adapter's higher `min_spt_core_version` of **0.31.0** is required for the identity-preserving `api listen --session-id` listener path. ## Distribution and update The adapter id, binary, and repository are all `omp-spt` / `BigscreenVR/omp-spt`. A multi-platform `adapter.spt` contains the shared manifest and extension plus the platform tool binary. `[update]` uses the published `gh_release` avenue. There is no secondary plugin reconciliation or reload step. ## Skill delivery gap No OMP-native command/skill distribution path has been proven for this adapter. Consequently, no command skills are shipped or advertised. Generic SPT operations remain available through the public `spt` CLI. Adding OMP commands later requires an explicit OMP extension registration and packaging contract plus focused tests; a foreign plugin mechanism is not an acceptable substitute. ## Release verification Deterministic checks cover the extension, launch resolver, manifest/schema, Psyche, history, digest, echo-commune, and archive shape. The release gate then uses one disposable **same-node** endpoint to prove: 1. fresh native bringup and attachable TUI; 2. message → ordinary OMP turn → correlated reply; 3. honest busy/idle state and graceful shutdown; 4. native resume and immutable in-TUI binding; 5. bounded listener recovery and fail-closed teardown; 6. both ReadyAgent and LiveAgent behavior. diff --git a/SCOPE.md b/SCOPE.md index e662a4e..0f99f15 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -1,108 +1,108 @@ # omp-spt scope Status: **locked** (2026-07-14) ## Product `omp-spt` is the OMP-native SPT harness adapter. It starts or resumes a native Oh My Pi TUI inside an spt-core broker PTY and loads a packaged OMP extension that owns messaging and lifecycle integration. The public surface is deliberately narrow: - adapter id and binary: `omp-spt`; - release repository: `BigscreenVR/omp-spt`; - host application: Oh My Pi (`omp`); - endpoint types: `ReadyAgent` and `LiveAgent`; - distribution: one multi-platform `adapter.spt`; - updates: the manifest's `gh_release` avenue. ## In scope 1. **Native hosted sessions** - Fresh and native-resume launch through a validating shim. - OMP remains the terminal owner and renders the attachable TUI. - The extension binds the real OMP session id to the requested endpoint. -2. **Extension-owned delivery** - - One serialized custody queue. - - Message stub plus the full SPT event context in the same OMP turn. +2. **Core-custodied delivery** + - spt-core owns durable message custody; the extension adds no second spool. + - Listener events surface immediately through OMP's native message rail. + - Active-only traffic surfaces independently through authenticated polling at every active context boundary. - Assistant output remains local; outbound peer messaging requires explicit CLI or shortform use. - - Honest busy/idle state. - - Bounded listener restart followed by loud session teardown and OMP - shutdown if delivery cannot recover. + - Honest busy/idle state uses prompt-start transitions plus OMP steady-state truth. + - Established endpoints preserve local OMP work while listener, state, or poll communications recover visibly. 3. **Immutable identity** - One OMP session per endpoint lifetime. - In-TUI session switching, branching, creation, and resume are blocked. - A different session requires endpoint stop plus explicit relaunch. 4. **Live continuity** - ReadyAgent and LiveAgent only. - Bounded, per-event OMP Psyche turns; no resident adapter-side Psyche loop. - OMP history, digest, and echo-commune implementations. - Commune and signoff drops under project-local `.spt/`. 5. **Public-contract verification** - Published manifest schema and public `spt api`/CLI surface only. - Minimum spt-core version `0.31.0`, required for identity-preserving listener authentication; v0.29.0 remains the relative continuity-path floor. - Deterministic unit/integration checks plus a same-node native endpoint release gate. 6. **Capability parity** - Extension-native ready/live activation for already-open OMP sessions, including explicit live auto-resume and extension-owned listener custody. - Safe-boundary delivery during active turns and automatic receivability restoration after abnormal turn termination. - Startup briefs, targeted hints, compatible-update notices, packaged commune/signoff/role/setup skills, native checkpoint continuity, and the cross-harness peer-message shortform. - OMP-native provider/profile routing satisfies alternate-routing parity. - Windows x86-64, GNU Linux x86-64, and static Linux x86-64 musl helper targets, each with durable machine-readable release evidence. ## Out of scope - A headless RPC bridge or any second endpoint topology. - Any foreign-harness plugin, hook, context-injection, command launcher, update reconciliation, or model-wrapper compatibility layer. - Worker endpoint advertisement for OMP subagents. - Migration of inherited `.spt` predecessor continuity. - Switching the bound OMP session inside a running endpoint. - Cross-node adapter tests; subnet transport is an spt-core responsibility. - An adapter-owned copy of spt-core state, transport, spool, or auth logic. ## Skill policy The public OMP `omp-plugins` provider is the canonical delivery seam for model-driven adapter capabilities packaged beside the loaded extension. The adapter will ship OMP-native skills for commune (including checkpoint mode), signoff, durable role, and the agent-driven half of setup; release verification must prove discovery from the installed `adapter.spt` layout. Deterministic lifecycle infrastructure remains extension-owned: ready/live activation, binding, listener custody, delivery, activity, and shutdown are not delegated to model-executed skills. ## Release boundary -A release must prove fresh launch, attachability, local delivery and correlated -reply, lifecycle state, native resume, blocked in-TUI switching, graceful -shutdown, listener fail-closed behavior, and both endpoint types. It does not -re-prove spt-core's subnet transport. +A release must prove fresh launch, attachability, listener and active-poll +delivery, correlated explicit reply, lifecycle state, native resume, blocked +in-TUI switching, graceful shutdown, nonfatal communications recovery, and both +endpoint types. It does not re-prove spt-core's subnet transport. ## Decision index | Decision | Record | Status | |---|---|---| | OMP-only clean cutover | ADR-0008 | Locked | | Native OMP for every endpoint | ADR-0009 | Locked | -| Delivery self-heals or closes | ADR-0010 | Locked | +| Delivery self-heals without stopping local work | ADR-0010 | Locked | | Endpoint/session binding is immutable | ADR-0011 | Locked | | Continuity lives under `.spt/` | ADR-0012 | Locked | | Release gate stops at adapter boundary | ADR-0013 | Locked | | Extension-owned session activation | ADR-0015 | Locked | | Agent capabilities split by native seam | ADR-0016 | Locked | | Active-turn delivery uses safe boundaries | ADR-0017 | Locked | | Checkpoint resets context natively | ADR-0018 | Locked | diff --git a/adapter/omp-spt.toml b/adapter/omp-spt.toml index b0e72ab..fd4b0d0 100644 --- a/adapter/omp-spt.toml +++ b/adapter/omp-spt.toml @@ -1,119 +1,127 @@ # omp-spt — Oh My Pi harness adapter for spt-core. # # Native OMP owns every hosted terminal. The adapter binary is a launch, # digest/history, Psyche, and echo-commune helper; OMP's packaged extension owns # bind, delivery, activity state, reply, and shutdown inside the native TUI. # This manifest is authored only against the published spt-core manifest and # CLI contracts. See OMP-ADAPTER-PLAN.md and ADRs 0008-0013. [adapter] name = "omp-spt" kind = "harness" version = "0.3.12" # v0.31.0 added identity-preserving `api listen --session-id`, which keeps the # listener on the native OMP session already recorded by the extension's bind. min_spt_core_version = "0.31.0" # [impl->REQ-OMP-READY-LIVE] hostable_types = ["LiveAgent", "ReadyAgent"] # Only the genuine OMP host process may resolve to this adapter. host_binaries = ["omp"] shortcut_basename = "omp" # Update through this repository's GitHub release. The packaged fat archive # contains all three supported target binaries plus the shared native plugin. # [impl->REQ-DIST-ADAPTER-RELEASE] [update] avenue = "gh_release" repo = "BigscreenVR/omp-spt" transport = "gh" message = """ **omp-spt updated.** The native OMP extension + extractors refreshed in place — no reload step: OMP loads the packaged extension fresh on each endpoint bringup. - Running endpoints keep the OLD hosting path until restarted: `spt endpoint stop ` then `spt endpoint run --adapter omp-spt --id ` picks up the new one. - Bring up a fresh Librarian endpoint: `spt endpoint run --adapter omp-spt --id --create`. """ +# OMP's native extension is the non-disruptive injection seam in both activity +# states. Busy active-only traffic is pulled at context boundaries; idle and +# unrestricted listener traffic uses the same native user-message API. +# [impl->REQ-OMP-CORE-DELIVERY] +[inject] +activity = ["hook"] +idle = ["hook"] + [identity] # OMP reports the bound session id after spawn; the process-tree fallback is # the native host executable, never the adapter helper. session_id_source = "post_spawn" parent_ancestor_name = "omp" [session] # [impl->REQ-OMP-CONTINUITY-DROPS] commune_dir = ".spt" signoff_dir = ".spt" # The base manifest is live-capable. ReadyAgent and LiveAgent use the same OMP # endpoint; only LiveAgent activates the per-event Psyche role. # [impl->REQ-PSYCHE-EPHEMERAL-SHIM] [session.psyche_init] command = "omp-spt psyche-omp --id {id} --session-id {session_id} --psyche-context-file {psyche_context_file}" # Prevent a child Psyche from inheriting its parent's SPT identity. env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] keys = ["id", "session_id", "psyche_context_file"] # Each Psyche event is one bounded OMP turn. The context file is read by the # shim, the event arrives on stdin, stdout carries the result, and exit 95 asks # [impl->REQ-PSYCHE-EPHEMERAL-SHIM] [session.psyche_resume] command = "omp-spt psyche-omp --id {id} --session-id {session_id} --psyche-context-file {psyche_context_file}" detach = false # Prevent a child Psyche from inheriting its parent's SPT identity. env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] keys = ["id", "session_id", "psyche_context_file"] # Fresh endpoints launch validated native OMP with the packaged extension. The # launch shim snapshots only non-secret OMP locator/profile/executable selectors # under the endpoint project's `.spt` before OMP takes over the broker PTY. It # also forwards the daemon-advertised node label for operator-facing naming. [session.self] # [impl->REQ-OMP-EXECUTABLE-RESOLUTION] [impl->REQ-OMP-SESSION-TITLES] command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --extension {adapter_dir}/strings/omp-spt.mjs" keys = ["id", "node"] # Resume refreshes the same endpoint snapshot, then uses OMP's native session # selector with the packaged extension and the same naming inputs. [session.resume] # [impl->REQ-OMP-EXECUTABLE-RESOLUTION] [impl->REQ-OMP-SESSION-TITLES] command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs" keys = ["id", "node", "session_id"] # The bounded summarizer reads the selected OMP session JSONL and runs one # extension-free OMP turn. A missing transcript is an empty delta; a real OMP # failure is reported to spt-core. # [impl->REQ-SESSION-ECHO-COMMUNE] [session.echo_commune] command = "omp-spt echo-commune-omp --id {id} --session-id {session_id}" detach = false recursion_guard_env = "SPT_ECHO_COMMUNE" # Prevent the summarizer from inheriting its parent's SPT identity. env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] keys = ["id", "session_id"] # History is the opaque JSONL for exactly one OMP session. # [impl->REQ-HISTORY-FETCHER] [history] strategy = "fetcher" fetcher = "omp-spt history-omp --session {session_id} --project-dir \"{cwd}\"" # Public spt docs say `[env.*] direction = "read"` values survive the daemon # boundary as substitutions for echo, digest, and Psyche. Installed spt 0.31.0 # rejects those placeholders during registration and does not inherit captured # values into daemon children. The project-local launch snapshot above is the # smallest non-secret substitute; no API key, token, or credential is persisted. # The native extension uses the endpoint id injected by spt-core when binding the OMP session. [env.SPT_ENDPOINT_ID] direction = "inject" value = "{id}" # Message delivery is extension-owned; no hook or PTY-translation surface is # declared by the native adapter. # The fetcher locates one OMP session JSONL and emits the published digest # record stream. # [impl->REQ-DIST-DIGEST-EXTRACTOR] [digest] diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs index 3849ce0..0e7d9fd 100644 --- a/adapter/strings/omp-spt.mjs +++ b/adapter/strings/omp-spt.mjs @@ -1,101 +1,104 @@ import { spawn } from "node:child_process"; const ADAPTER = "omp-spt"; const BUSY_TITLE_GLYPHS = [..."⣾⣽⣻⢿⡿⣟⣯⣷"]; const IDLE_TITLE_GLYPH = "○"; const TITLE_FRAME_MS = 500; // [impl->REQ-OMP-SESSION-TITLES] export function endpointDisplayName(id, node, project) { const endpoint = String(id ?? "").trim(); const nodeName = String(node ?? "").trim(); const projectName = String(project ?? "").trim(); if (!nodeName) return endpoint; return projectName ? `${endpoint} @ ${nodeName} (${projectName}/)` : `${endpoint} @ ${nodeName}`; } -export function endpointInlineStatus(id, node, project, theme) { +export function endpointInlineStatus(id, node, project, theme, recovering = false) { const text = endpointDisplayName(id, node, project); - return theme?.fg ? theme.fg("statusLineModel", text) : text; + const identity = theme?.fg ? theme.fg("statusLineModel", text) : text; + if (!recovering) return identity; + const warning = " · comms recovering..."; + return identity + (theme?.fg ? theme.fg("warning", warning) : warning); } export function decodeBody(body) { return body .replaceAll("
", "\n") .replaceAll("
", "\n") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll(""", '"') .replaceAll("&", "&"); } function protocolError(message) { const error = new Error(`invalid spt EVENT stream: ${message}`); error.code = "SPT_PROTOCOL_ERROR"; return error; } function parseEventTag(tag) { if (!tag.startsWith("]*)"/.exec(tag.slice(cursor)); if (!match) return { error: protocolError("malformed EVENT attributes") }; const [, name, value] = match; if (Object.hasOwn(attributes, name)) { return { error: protocolError(`duplicate EVENT ${name} attribute`) }; } attributes[name] = value; cursor += match[0].length; } if (!attributes.type) return { error: protocolError("missing EVENT type attribute") }; if (attributes.type === "msg" && !attributes.from) { return { error: protocolError("missing EVENT from attribute") }; } return { attributes }; } function findEventClose(raw, bodyStart) { let cursor = bodyStart; let depth = 1; while (true) { const open = raw.indexOf("", cursor); if (close < 0) return -1; if (open >= 0 && open < close) { const openEnd = raw.indexOf(">", open); if (openEnd < 0 || openEnd >= close) return -1; if (!parseEventTag(raw.slice(open, openEnd)).error) depth += 1; cursor = openEnd + 1; continue; } depth -= 1; if (depth === 0) return close; cursor = close + "".length; } } export function drainEvents(raw, options = {}) { const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY; const maxFrameChars = options.maxFrameChars ?? DEFAULT_LISTENER_BUFFER_LIMIT; const events = []; let cursor = 0; while (true) { const start = raw.indexOf("", start); if (openEnd < 0) { if (raw.length - start > maxFrameChars) { return { error: protocolError(`EVENT frame exceeded ${maxFrameChars} characters`), events, rest: raw.slice(start), }; @@ -141,161 +144,161 @@ export function drainEvents(raw, options = {}) { if (events.length >= maxEvents) return { events, rest: raw.slice(cursor) }; } } function messageText(message) { if (typeof message?.content === "string") return message.content; return (message?.content ?? []) .filter((part) => part?.type === "text" && typeof part.text === "string") .map((part) => part.text) .join(""); } function assistantMessageIdentity(message) { if (message?.role !== "assistant") return undefined; if (typeof message.responseId === "string" && message.responseId) { return `response:${message.provider ?? ""}:${message.model ?? ""}:${message.responseId}`; } if (Number.isFinite(message.timestamp)) { return `timestamp:${message.provider ?? ""}:${message.model ?? ""}:${message.timestamp}`; } return undefined; } function captureAssistantBaseline(messages) { const assistants = (messages ?? []).filter((message) => message?.role === "assistant"); const identities = new Set(); for (const message of assistants) { const identity = assistantMessageIdentity(message); if (identity !== undefined) identities.add(identity); } return { count: assistants.length, identities }; } function assistantAfterBaseline(messages, baseline) { const assistants = (messages ?? []).filter((message) => message?.role === "assistant"); for (let index = assistants.length - 1; index >= 0; index -= 1) { const identity = assistantMessageIdentity(assistants[index]); if (identity !== undefined && !baseline.identities.has(identity)) return assistants[index]; } for (let index = assistants.length - 1; index >= baseline.count; index -= 1) { if (assistantMessageIdentity(assistants[index]) === undefined) return assistants[index]; } return undefined; } const SUCCESSFUL_ASSISTANT_STOP_REASONS = new Set(["stop", "length", "toolUse"]); function successfulAssistant(message) { return SUCCESSFUL_ASSISTANT_STOP_REASONS.has(message?.stopReason); } function firstLine(text) { return text.split(/\r?\n/).find((line) => line.trim()) ?? ""; } function errorSummary(error) { const detail = error instanceof Error ? error.message : String(error); return firstLine(detail).trim() || "unknown error"; } function senderStub(sender) { const escaped = sender .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); return ``; } export function formatInboundEnvelope(envelope) { const openingEnd = envelope.indexOf(">"); const closingStart = envelope.lastIndexOf(""); if (openingEnd < 0 || closingStart <= openingEnd) return envelope; return `${envelope.slice(0, openingEnd + 1)}\n${envelope.slice(openingEnd + 1, closingStart)}\n${envelope.slice(closingStart)}`; } function injectEnvelope(messages, item) { - const index = messages.findLastIndex( + const index = messages.findIndex( (message) => message?.role === "user" && messageText(message) === item.stub, ); if (index < 0) return messages; const original = messages[index]; const envelope = formatInboundEnvelope(item.envelope); const content = typeof original.content === "string" ? `${original.content}\n\n${envelope}` : [...(original.content ?? []), { type: "text", text: `\n\n${envelope}` }]; const injected = [...messages]; injected[index] = { ...original, content }; return injected; } function maskMarkdownCode(text) { const source = String(text); const masked = source.split(""); let fenceCharacter; let fenceLength = 0; let lineStart = 0; while (lineStart < source.length) { const newline = source.indexOf("\n", lineStart); const lineEnd = newline < 0 ? source.length : newline + 1; const line = source.slice(lineStart, newline < 0 ? lineEnd : newline).replace(/\r$/, ""); const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line); const indentedCode = !fenceCharacter && /^(?: {4}|\t)/.test(line); let maskLine = Boolean(fenceCharacter || fence || indentedCode); if (fence) { const character = fence[1][0]; if (!fenceCharacter) { fenceCharacter = character; fenceLength = fence[1].length; } else if (character === fenceCharacter && fence[1].length >= fenceLength) { fenceCharacter = undefined; fenceLength = 0; } else { maskLine = true; } } if (maskLine) { for (let index = lineStart; index < lineEnd; index += 1) { if (source[index] !== "\r" && source[index] !== "\n") masked[index] = " "; } } lineStart = lineEnd; } for (let cursor = 0; cursor < source.length; cursor += 1) { if (masked[cursor] !== "`") continue; let runLength = 1; while (masked[cursor + runLength] === "`") runLength += 1; const delimiter = "`".repeat(runLength); let closing = source.indexOf(delimiter, cursor + runLength); while ( closing >= 0 && (source[closing - 1] === "`" || source[closing + runLength] === "`") ) { closing = source.indexOf(delimiter, closing + runLength); } if (closing < 0) { cursor += runLength - 1; continue; } for (let index = cursor; index < closing + runLength; index += 1) { if (source[index] !== "\r" && source[index] !== "\n") masked[index] = " "; } cursor = closing + runLength - 1; } return masked.join(""); } // [impl->REQ-PARITY-PEER-SHORTFORM] export function parsePeerShortforms(text) { const source = String(text ?? ""); const prose = maskMarkdownCode(source); const shortforms = []; const pattern = /@<([\s\S]*?)@>/g; for (const match of prose.matchAll(pattern)) { const inner = match[1]; const separator = inner.search(/\s/); @@ -699,218 +702,245 @@ export function createOmpSpt(overrides = {}) { }); }; const endpointIdPattern = /^[A-Za-z0-9_-]+$/; const commandCompletions = (cachedIds, includeAuto) => (prefix) => { const values = includeAuto ? ["--auto", ...cachedIds] : [...cachedIds]; const matches = values .filter((value) => value.startsWith(prefix.trim())) .map((value) => ({ value, label: value })); return matches.length > 0 ? matches : null; }; function normalizePath(value) { let normalized = String(value ?? "").replaceAll("\\", "/"); while ( normalized.endsWith("/") && normalized.length > 1 && !/^[A-Za-z]:\/$/.test(normalized) ) { normalized = normalized.slice(0, -1); } return platform === "win32" ? normalized.toLowerCase() : normalized; } function latestDigestTimestamp(digest) { let latest = Number.NEGATIVE_INFINITY; for (const turn of digest?.turns ?? []) { for (const entry of turn?.entries ?? []) { for (const value of Object.values(entry ?? {})) { const parsed = Date.parse(value?.ts ?? ""); if (Number.isFinite(parsed)) latest = Math.max(latest, parsed); } } } return latest; } function newestCoreUpdate(rawNotifications, currentVersion) { const notifications = parseJson(rawNotifications, "spt notif list").notifs ?? []; let newest; for (const notification of notifications) { if ( notification?.from_id !== "spt-update" || notification?.kind !== "consent" || notification?.state === "dismissed" ) { continue; } const version = parseVersion(notification.head); if (!version || compareVersions(version, currentVersion) <= 0) continue; if (!newest || compareVersions(version, newest) > 0) newest = version; } return newest; } return function ompSpt(pi) { let id = env.SPT_ENDPOINT_ID?.trim() || undefined; const initialId = id; let activationType; let activationPromise; let activationCommandsInFlight = 0; let activated = false; let startupBriefPending = false; let updateNoticesPromise = Promise.resolve([]); let updateNotices = []; let updateNoticesReady = false; let updateNoticesPending = false; let firstTurnContextStarted = false; let cachedReadyIds = []; let cachedLiveIds = []; let sid; let token; let listener; let listenerBuffer = ""; let listenerRestartCount = 0; let listenerStableTimer; let restartTimer; - let dispatchTimer; + let stateRetryTimer; + let stateRetryAttempt = 0; let bindPromise; let agentActive = false; let desiredState = "idle"; - let dispatching = false; let turnCompletionPromise; let turnAssistantBaseline = captureAssistantBaseline([]); let turnContextObserved = false; let observedAssistantBaseline = captureAssistantBaseline([]); let listenerTerminationPromise; let shutdownMode = false; let shutdownDeadlineExpired = false; const activeCommands = new Map(); const retryWaiters = new Set(); - let current; let stopping = false; let ui; let titleTimer; let titleFrame = 0; const displayName = () => endpointDisplayName(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT); const setWindowTitle = (glyph) => ui?.setTitle(`${glyph} ${displayName()}`); const stopTitleAnimation = () => { if (titleTimer !== undefined) { clearRepeatingTimer(titleTimer); titleTimer = undefined; } titleFrame = 0; }; const showIdleTitle = () => { stopTitleAnimation(); setWindowTitle(IDLE_TITLE_GLYPH); }; const showBusyTitle = () => { stopTitleAnimation(); setWindowTitle(BUSY_TITLE_GLYPHS[titleFrame]); titleFrame = (titleFrame + 1) % BUSY_TITLE_GLYPHS.length; titleTimer = setRepeatingTimer(() => { setWindowTitle(BUSY_TITLE_GLYPHS[titleFrame]); titleFrame = (titleFrame + 1) % BUSY_TITLE_GLYPHS.length; }, TITLE_FRAME_MS); titleTimer?.unref?.(); }; let runtimeCtx; let endpointState; - let stateOperation = Promise.resolve(); let endPromise; let fatalPromise; let teardownPromise; let acceptedBytes = 0; let overflowItem; - const queue = []; + const pendingListener = []; + const commsFailures = new Set(); const logError = (message, error) => { pi.logger.error(message, { error: errorSummary(error) }); ui?.notify(`${message}: ${errorSummary(error)}`, "error"); }; + function renderEndpointStatus() { + if (!id || !ui) return; + ui.setStatus( + "omp-spt", + endpointInlineStatus( + id, + env.OMP_SPT_NODE, + env.OMP_SPT_PROJECT, + ui.theme, + commsFailures.size > 0, + ), + ); + } + + // [impl->REQ-OMP-COMMS-RECOVERY] + function markCommsFailure(component, message, error) { + const first = !commsFailures.has(component); + commsFailures.add(component); + pi.logger.error(message, { error: errorSummary(error) }); + if (first) ui?.notify(`${message}: ${errorSummary(error)}`, "warning"); + renderEndpointStatus(); + } + + function clearCommsFailure(component) { + if (!commsFailures.delete(component)) return; + renderEndpointStatus(); + } + function runCommand(args, input, options = {}) { if (shutdownDeadlineExpired) { return Promise.reject(new Error("omp-spt shutdown deadline expired")); } const timeoutMs = options.timeoutMs ?? (shutdownMode ? shutdownCommandTimeoutMs : commandTimeoutMs); const controller = new AbortController(); activeCommands.set(controller, { args, abortTimer: undefined }); let command; try { command = Promise.resolve( runSptCommand(args, input, { signal: controller.signal, timeoutMs, }), ); } catch (error) { activeCommands.delete(controller); return Promise.reject(error); } if (customRunSptCommand) { const rawCommand = command; command = new Promise((resolve, reject) => { let timer; let finished = false; const finish = (error, value) => { if (finished) return; finished = true; if (timer !== undefined) clearTimer(timer); controller.signal.removeEventListener("abort", onAbort); if (error === undefined) resolve(value); else reject(error); }; const onAbort = () => finish( controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`${commandLabel(args)} aborted`), ); controller.signal.addEventListener("abort", onAbort, { once: true }); if (shutdownMode) { timer = setTimer( () => controller.abort( new Error(`${commandLabel(args)} timed out after ${timeoutMs}ms`), ), timeoutMs, ); timer?.unref?.(); } rawCommand.then( (value) => finish(undefined, value), (error) => finish(error), ); }); } return command.finally(() => { const active = activeCommands.get(controller); if (active?.abortTimer !== undefined) clearTimer(active.abortTimer); activeCommands.delete(controller); }); } // [impl->REQ-PARITY-UPDATE-NOTICE] async function detectUpdateNotices() { const [coreVersionResult, notificationsResult, adapterVersionResult, latestAdapterResult] = await Promise.allSettled([ runCommand(["--version"], undefined, { timeoutMs: updateProbeTimeoutMs }), runCommand(["--json", "notif", "list"], undefined, { timeoutMs: updateProbeTimeoutMs, }), runCommand(["adapter", "version", ADAPTER], undefined, { timeoutMs: updateProbeTimeoutMs, }), fetchLatestAdapterVersion(), ]); const notices = []; if ( coreVersionResult.status === "fulfilled" && notificationsResult.status === "fulfilled" @@ -1081,208 +1111,210 @@ export function createOmpSpt(overrides = {}) { try { const digest = parseJson( await runCommand([ "--json", "endpoint", "digest", candidate.id, "--last", "1", ]), `spt endpoint digest ${candidate.id}`, ); return { ...candidate, lastActiveAt: latestDigestTimestamp(digest), }; } catch { return { ...candidate, lastActiveAt: Number.NEGATIVE_INFINITY }; } }), ) ).filter((candidate) => Number.isFinite(candidate.lastActiveAt)); recent.sort( (left, right) => right.lastActiveAt - left.lastActiveAt || left.id.localeCompare(right.id), ); if (recent.length === 0) { ctx.ui.notify( "No compatible prior omp-spt live identity has recorded activity; use `/live `", "error", ); return undefined; } let candidate = recent[0]; const ties = recent.filter((item) => item.lastActiveAt === candidate.lastActiveAt); if (ties.length > 1) { const selected = await ctx.ui.select( "Select equally recent live endpoint", ties.map((item) => item.id), ); if (!selected) return undefined; candidate = ties.find((item) => item.id === selected); } const confirmed = await ctx.ui.confirm( "Resume live endpoint?", `${candidate.id} was the most recently active compatible live endpoint (${new Date( candidate.lastActiveAt, ).toISOString()}). Bind this OMP session to it?`, ); return confirmed ? candidate.id : undefined; } // [impl->REQ-PARITY-READY-ACTIVATION] // [impl->REQ-PARITY-LIVE-ACTIVATION] async function activateEndpoint(nextId, type, ctx, options = {}) { if (!endpointIdPattern.test(nextId ?? "")) { ctx.ui.notify( "SPT endpoint ids may contain only letters, numbers, `-`, and `_`", "error", ); return false; } if (activated || token) { ctx.ui.notify( `This OMP session is immutably bound to ${id}; stop it before activating another identity`, "warning", ); return false; } if (stopping) { ctx.ui.notify("This OMP session is already shutting down", "error"); return false; } if (activationPromise) return activationPromise; runtimeCtx = ctx; ui = ctx.ui; sid = ctx.sessionManager.getSessionId(); id = nextId; activationType = type; - let bound = false; const operation = (async () => { const bindArgs = [ "api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid, ]; if (type) bindArgs.push("--type", type); if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); bindPromise = (async () => { const response = await runCommand(bindArgs); token = response.match(/\btoken=([^\s]+)/)?.[1]; if (!token) throw new Error("spt bind response did not include token="); - bound = true; })(); try { await bindPromise; - await syncDesiredState(); + try { + await syncDesiredState(); + } catch { + // Bind established the endpoint; communications now recover in place. + } if (stopping) { await teardownSession("OMP session shut down before initialization completed"); return false; } activated = true; startupBriefPending = true; ui.setStatus( "omp-spt", endpointInlineStatus(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT, ui.theme), ); // [impl->REQ-OMP-SESSION-TITLES] pi.setSessionName(displayName()); showIdleTitle(); startListener(); beginUpdateProbe(); if (options.announce) { ui.notify( `OMP session activated as ${type === "live_agent" ? "live" : "ready"} endpoint ${id}`, "info", ); } return true; } catch (error) { ui.setStatus("omp-spt", "spt bind failed"); - if (options.fatal || bound) { - await failClosed(`omp-spt could not bind ${id}`, error); + if (options.fatal) { + await failActivation(`omp-spt could not bind ${id}`, error); return false; } pi.logger.error(`omp-spt could not bind ${id}`, { error: errorSummary(error), }); ui.notify(`omp-spt could not bind ${id}: ${errorSummary(error)}`, "error"); id = undefined; activationType = undefined; bindPromise = undefined; token = undefined; return false; } })(); activationPromise = operation; try { return await operation; } finally { if (!activated && activationPromise === operation) activationPromise = undefined; } } async function handleActivationCommand(type, args, ctx) { activationCommandsInFlight += 1; try { const command = type === "live_agent" ? "live" : "ready"; const trimmed = args.trim(); let nextId; if (trimmed === "--auto") { if (type !== "live_agent") { ctx.ui.notify("`--auto` is supported only by `/live`", "error"); return; } nextId = await chooseAutoResume(ctx); } else if (!trimmed) { nextId = await chooseIdentity(type, ctx); } else if (/\s/.test(trimmed) || trimmed.startsWith("-")) { ctx.ui.notify( `Usage: /${command} ${command === "live" ? " | --auto" : ""}`, "error", ); return; } else { nextId = trimmed; } if (!nextId) return; await activateEndpoint(nextId, type, ctx, { announce: true }); } finally { activationCommandsInFlight -= 1; } } pi.registerCommand("ready", { description: "Activate this OMP session as a ready SPT endpoint", getArgumentCompletions: commandCompletions(cachedReadyIds, false), handler: (args, ctx) => handleActivationCommand("ready_agent", args, ctx), }); pi.registerCommand("live", { description: "Activate this OMP session as a live SPT endpoint", getArgumentCompletions: commandCompletions(cachedLiveIds, true), handler: (args, ctx) => handleActivationCommand("live_agent", args, ctx), }); async function resolveActivationType() { if (activationType) return activationType; const info = parseJson( await runCommand(["--json", "api", "endpoint-info", id]), `spt api endpoint-info ${id}`, ); if (!["live_agent", "ready_agent"].includes(info.endpoint_type)) { throw new Error("endpoint-info did not report live_agent or ready_agent"); } activationType = info.endpoint_type; return activationType; } // [impl->REQ-PARITY-CHECKPOINT] pi.registerTool({ name: "spt_checkpoint", label: "SPT Checkpoint", description: @@ -1332,625 +1364,658 @@ export function createOmpSpt(overrides = {}) { "Preserve only the durable state needed to continue from the just-saved SPT commune.", }); if (stopping) throw new Error("OMP session began shutting down during checkpoint"); pi.sendMessage( { customType: "omp-spt-checkpoint-wake", content: wake, display: true, attribution: "user", }, { deliverAs: "nextTurn", triggerTurn: true }, ); return { content: [ { type: "text", text: `SPT checkpoint complete for ${id}; native continuation queued.`, }, ], details: { ok: true, endpoint: id }, }; } catch (error) { return { content: [ { type: "text", text: `SPT checkpoint failed: ${errorSummary(error)}`, }, ], details: { ok: false, reason: errorSummary(error) }, isError: true, }; } }, }); function abortActiveCommands(reason, allowBindGrace = false) { for (const [controller, active] of activeCommands) { const isBind = active.args[0] === "api" && active.args[3] === "bind"; if (allowBindGrace && isBind && active.abortTimer === undefined) { active.abortTimer = setTimer( () => controller.abort(reason), shutdownCommandTimeoutMs, ); active.abortTimer?.unref?.(); continue; } controller.abort(reason); } } function waitForRetry(delay) { if (shutdownMode) return Promise.resolve(); return new Promise((resolve) => { let timer; const finish = () => { if (timer !== undefined) clearTimer(timer); retryWaiters.delete(finish); resolve(); }; retryWaiters.add(finish); timer = setTimer(finish, delay); timer?.unref?.(); }); } function enterShutdownMode() { if (shutdownMode) return; shutdownMode = true; const reason = new Error("omp-spt command interrupted for bounded shutdown"); abortActiveCommands(reason, true); for (const finish of [...retryWaiters]) finish(); } function authArgs() { if (!token) throw new Error("bind did not return an authentication token"); return ["--token", token]; } - function setState(state) { - if (!sid || !token || stopping) return Promise.resolve(); - const operation = stateOperation.catch(() => {}).then(async () => { - if (endpointState === state || stopping) return; + function scheduleStateRetry() { + if (stopping || stateRetryTimer !== undefined) return; + const index = Math.min(stateRetryAttempt, Math.max(0, restartDelaysMs.length - 1)); + const delay = restartDelaysMs[index] ?? 0; + stateRetryAttempt += 1; + stateRetryTimer = setTimer(() => { + stateRetryTimer = undefined; + if (stopping) return; + void setState(desiredState).catch(() => {}); + }, delay); + stateRetryTimer?.unref?.(); + } + + // [impl->REQ-OMP-COMMS-RECOVERY] + async function setState(state) { + if (!sid || !token || stopping) return; + if (endpointState === state) { + if (state === desiredState) clearCommsFailure("state"); + return; + } + try { await runCommand(["api", "--adapter", ADAPTER, "state", state, id, ...authArgs()]); endpointState = state; - }); - stateOperation = operation; - return operation; + if (state === desiredState) { + stateRetryAttempt = 0; + if (stateRetryTimer !== undefined) { + clearTimer(stateRetryTimer); + stateRetryTimer = undefined; + } + clearCommsFailure("state"); + } else { + markCommsFailure( + "state", + "omp-spt activity changed during state publication; reconciling", + new Error(`published stale ${state} state while current OMP state is ${desiredState}`), + ); + scheduleStateRetry(); + } + } catch (error) { + markCommsFailure("state", `omp-spt could not mark the endpoint ${state}`, error); + scheduleStateRetry(); + throw error; + } } async function syncDesiredState() { if (!bindPromise) return; await bindPromise; - while (!stopping && endpointState !== desiredState) { - await setState(desiredState); - } + await setState(desiredState); } function endSession() { if (!sid || !token) return Promise.resolve(); if (!endPromise) { const operation = (async () => { - await stateOperation.catch(() => {}); await runCommand(["api", "--adapter", ADAPTER, "session-end", id, ...authArgs()]); endpointState = undefined; })(); endPromise = operation; void operation.catch(() => { if (endPromise === operation) endPromise = undefined; }); } return endPromise; } async function endSessionWithRetry() { for (let attempt = 0; ; attempt += 1) { try { await endSession(); return; } catch (error) { if (shutdownMode || attempt >= sessionEndRetryDelaysMs.length) throw error; const delay = sessionEndRetryDelaysMs[attempt]; pi.logger.error( `omp-spt session teardown failed; retrying ${ attempt + 1 }/${sessionEndRetryDelaysMs.length} in ${delay}ms`, { error: errorSummary(error) }, ); await waitForRetry(delay); } } } function releaseItem(item) { if (!item?.accounted) return; item.accounted = false; acceptedBytes -= item.acceptedBytes; } function beginListenerTermination(child, label) { if (listenerTerminationPromise) return listenerTerminationPromise; const operation = (async () => { try { await terminateChild(child, label, { clearTimer, forceMs: killForceMs, graceMs: killGraceMs, setTimer, }); } catch (error) { pi.logger.error("omp-spt could not reap the listener", { error: errorSummary(error), }); } })(); listenerTerminationPromise = operation; void operation.then(() => { if (listenerTerminationPromise === operation) listenerTerminationPromise = undefined; }); return operation; } async function stopResources() { stopTitleAnimation(); - if (dispatchTimer !== undefined) { - clearTimer(dispatchTimer); - dispatchTimer = undefined; + if (stateRetryTimer !== undefined) { + clearTimer(stateRetryTimer); + stateRetryTimer = undefined; } if (restartTimer !== undefined) { clearTimer(restartTimer); restartTimer = undefined; } if (listenerStableTimer !== undefined) { clearTimer(listenerStableTimer); listenerStableTimer = undefined; } const child = listener; listener = undefined; listenerBuffer = ""; if (child) { await beginListenerTermination(child, "spt api listener"); } else { await listenerTerminationPromise; } } function releasePending() { - const pending = current ? [current, ...queue] : [...queue]; + const pending = [...pendingListener]; if (overflowItem) pending.push(overflowItem); - current = undefined; - queue.length = 0; + pendingListener.length = 0; overflowItem = undefined; for (const item of pending) releaseItem(item); } function teardownSession(pendingReason) { if (!teardownPromise) { stopping = true; const operation = (async () => { releasePending(); await bindPromise?.catch(() => {}); try { await endSessionWithRetry(); } finally { await stopResources(); } })(); teardownPromise = operation; void operation.catch(() => { if (teardownPromise === operation) teardownPromise = undefined; }); } return teardownPromise; } async function shutdownWithinBudget(pendingReason) { enterShutdownMode(); const teardown = teardownSession(pendingReason); let budgetTimer; const expired = new Promise((resolve) => { budgetTimer = setTimer(() => { budgetTimer = undefined; shutdownDeadlineExpired = true; const error = new Error( `omp-spt shutdown exceeded its ${shutdownBudgetMs}ms budget`, ); abortActiveCommands(error); for (const finish of [...retryWaiters]) finish(); resolve(false); }, shutdownBudgetMs); budgetTimer?.unref?.(); }); const completed = teardown.then( () => true, (error) => { logError("omp-spt session teardown failed", error); return true; }, ); const finished = await Promise.race([completed, expired]); if (budgetTimer !== undefined) clearTimer(budgetTimer); if (!finished) { pi.logger.error("omp-spt bounded shutdown expired", { error: `${shutdownBudgetMs}ms budget exhausted`, }); } } - // [impl->REQ-OMP-LISTENER-FAIL-CLOSED] - async function failClosed(message, error) { + async function failActivation(message, error) { if (stopping && shutdownMode) return teardownPromise ?? Promise.resolve(); if (fatalPromise) return fatalPromise; fatalPromise = (async () => { - ui?.setStatus("omp-spt", "spt failed"); + ui?.setStatus("omp-spt", "spt activation failed"); logError(message, error); try { - await teardownSession("endpoint stopped before your message could complete"); + await teardownSession("endpoint activation failed"); } catch (teardownError) { logError("omp-spt session teardown failed", teardownError); } runtimeCtx?.shutdown(); })(); return fatalPromise; } - function scheduleDispatch() { - if ( - stopping || - dispatching || - current || - queue.length === 0 || - dispatchTimer !== undefined - ) { - return; + // [impl->REQ-OMP-CORE-DELIVERY] + function submitListenerItem(item) { + if (stopping) return false; + item.stub ??= senderStub(item.from ?? "unknown"); + try { + pi.sendUserMessage(item.stub); + return true; + } catch (error) { + const index = pendingListener.indexOf(item); + if (index >= 0) pendingListener.splice(index, 1); + releaseItem(item); + logError("omp-spt could not submit your message to OMP", error); + return false; } - dispatchTimer = setTimer(() => { - dispatchTimer = undefined; - void dispatchNext().catch((error) => { - if (!stopping) return failClosed("omp-spt dispatch failed", error); - }); - }, 0); - dispatchTimer?.unref?.(); } - async function rejectItem(item, reason, error) { - if (stopping) return; - logError(`omp-spt ${reason}`, error); - if (current === item) { - current = undefined; - releaseItem(item); - } - if (!stopping) { - desiredState = "idle"; - try { - await setState("idle"); - } catch (stateError) { - await failClosed( - "omp-spt could not restore idle state after a failed submission", - stateError, - ); - } - } + function resubmitUnobservedListenerItems() { + for (const item of pendingListener) submitListenerItem(item); } - // [impl->REQ-OMP-EXTENSION-CUSTODY] - async function dispatchNext() { - if (stopping || dispatching || current || queue.length === 0) return; - dispatching = true; - const item = queue.shift(); - current = item; - try { - try { - desiredState = "busy"; - await setState("busy"); - } catch (error) { - await rejectItem(item, "could not accept your message", error); - return; - } - if (stopping) return; - item.stub = senderStub(item.from ?? "unknown"); - item.submitted = true; - item.hiddenSubmitted = false; - try { - pi.sendUserMessage(item.stub); - } catch (error) { - item.submitted = false; - await rejectItem(item, "could not submit your message to OMP", error); - } - } finally { - dispatching = false; - scheduleDispatch(); - } + function admitOverflowItem() { + if (!overflowItem || pendingListener.length >= acceptedQueueLimit) return; + const item = overflowItem; + overflowItem = undefined; + pendingListener.push(item); + submitListenerItem(item); } - // [impl->REQ-OMP-LISTENER-FAIL-CLOSED] + // [impl->REQ-OMP-COMMS-RECOVERY] function handleListenerDeath(reason) { listenerBuffer = ""; if (listenerStableTimer !== undefined) { clearTimer(listenerStableTimer); listenerStableTimer = undefined; } if (stopping || restartTimer !== undefined) return; - if (listenerRestartCount >= restartDelaysMs.length) { - void failClosed("omp-spt listener restart budget exhausted", reason); - return; - } const attempt = listenerRestartCount + 1; - const delay = restartDelaysMs[listenerRestartCount]; + const index = Math.min(listenerRestartCount, Math.max(0, restartDelaysMs.length - 1)); + const delay = restartDelaysMs[index] ?? 0; listenerRestartCount = attempt; - const message = `omp-spt listener stopped; restarting ${attempt}/${restartDelaysMs.length} in ${delay}ms`; - pi.logger.error(message, { error: errorSummary(reason) }); - ui?.setStatus("omp-spt", `spt reconnecting (${attempt}/${restartDelaysMs.length})`); - ui?.notify(message, "warning"); + markCommsFailure( + "listener", + `omp-spt listener stopped; retrying in ${delay}ms`, + reason, + ); restartTimer = setTimer(() => { restartTimer = undefined; startListener(); }, delay); restartTimer?.unref?.(); } function startListener() { if (stopping) return; const args = ["api", "--adapter", ADAPTER, "listen", id, "--session-id", sid]; if (env.OMP_SPT_SUBNET) args.push("--subnet", env.OMP_SPT_SUBNET); let child; try { child = spawnProcess(env.OMP_SPT_SPT_BIN || "spt", args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); } catch (error) { handleListenerDeath(error); return; } listener = child; listenerBuffer = ""; let dead = false; const died = (reason, alreadyExited) => { if (dead) return; dead = true; if (listener === child) listener = undefined; if (stopping || alreadyExited) { handleListenerDeath(reason); return; } const termination = beginListenerTermination(child, "dead spt api listener"); void termination.then(() => handleListenerDeath(reason)); }; if (listenerStableMs !== undefined) { listenerStableTimer = setTimer(() => { listenerStableTimer = undefined; - if (listener === child && !stopping) listenerRestartCount = 0; + if (listener === child && !stopping) { + listenerRestartCount = 0; + clearCommsFailure("listener"); + } }, listenerStableMs); listenerStableTimer?.unref?.(); } child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { if (listener !== child || stopping) return; listenerBuffer += String(chunk); if (listenerBuffer.length > listenerBufferLimit) { - void failClosed( - "omp-spt listener protocol corruption", - protocolError( - `EVENT buffer exceeded ${listenerBufferLimit} characters without a complete drain`, - ), + const error = protocolError( + `EVENT buffer exceeded ${listenerBufferLimit} characters without a complete drain`, ); + markCommsFailure("listener", "omp-spt listener protocol corruption", error); + died(error, false); return; } while (!stopping) { const drained = drainEvents(listenerBuffer, { maxEvents: 1, maxFrameChars: listenerBufferLimit, }); listenerBuffer = drained.rest; if (drained.error) { - void failClosed("omp-spt listener protocol corruption", drained.error); + markCommsFailure( + "listener", + "omp-spt listener protocol corruption", + drained.error, + ); + died(drained.error, false); return; } if (drained.events.length === 0) break; const event = drained.events[0]; - const acceptedCount = queue.length + (current ? 1 : 0); + const acceptedCount = pendingListener.length + (overflowItem ? 1 : 0); const eventBytes = Buffer.byteLength(event.envelope, "utf8"); + event.acceptedBytes = eventBytes; + event.accounted = true; + acceptedBytes += eventBytes; if ( acceptedCount >= acceptedQueueLimit || - acceptedBytes + eventBytes > acceptedBytesLimit + acceptedBytes > acceptedBytesLimit ) { overflowItem = event; - void failClosed( - "omp-spt inbound custody capacity exceeded", - new Error( - `accepted queue limit is ${acceptedQueueLimit} messages and ${acceptedBytesLimit} bytes`, - ), + const error = new Error( + `accepted listener limit is ${acceptedQueueLimit} messages and ${acceptedBytesLimit} bytes`, + ); + markCommsFailure( + "listener", + "omp-spt inbound listener capacity exceeded", + error, ); + died(error, false); return; } - event.acceptedBytes = eventBytes; - event.accounted = true; - acceptedBytes += eventBytes; - queue.push(event); + pendingListener.push(event); + submitListenerItem(event); } - if (!current && !dispatching) void dispatchNext(); }); child.stderr.on("data", (chunk) => pi.logger.debug("omp-spt listener", { output: String(chunk).trim() }), ); child.on("error", (error) => died(error, false)); child.on("close", (code, signal) => { const status = signal ? `signal ${signal}` : code; died(new Error(`spt api listen exited ${status}`), true); }); - ui?.setStatus( - "omp-spt", - endpointInlineStatus(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT, ui?.theme), - ); + renderEndpointStatus(); } // [impl->REQ-OMP-NATIVE-TUI] pi.on("session_start", async (_event, ctx) => { runtimeCtx = ctx; ui = ctx.ui; sid = ctx.sessionManager.getSessionId(); if (!initialId) return; await activateEndpoint(initialId, undefined, ctx, { fatal: true }); }); // [impl->REQ-OMP-SESSION-IMMUTABLE] const blockSessionChange = (description, ctx) => { if ( !activated && !token && !activationPromise && activationCommandsInFlight === 0 ) { return; } ctx.ui.notify( `omp-spt blocked the in-TUI ${description}; end this SPT session first`, "warning", ); return { cancel: true }; }; pi.on("session_before_switch", (event, ctx) => blockSessionChange(`${event.reason} session switch`, ctx), ); pi.on("session_before_branch", (_event, ctx) => blockSessionChange("session branch", ctx), ); // [impl->REQ-OMP-MESSAGE-CONTEXT] // [impl->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] - pi.on("context", (event) => { + // [impl->REQ-OMP-CORE-DELIVERY] + pi.on("context", async (event) => { const baseline = captureAssistantBaseline(event.messages); if (agentActive) { if (!turnContextObserved) { turnAssistantBaseline = baseline; turnContextObserved = true; } } else { observedAssistantBaseline = baseline; } let messages = event.messages; - if (current?.submitted && !current.hiddenSubmitted) { - current.assistantBaseline ??= baseline; - messages = injectEnvelope(messages, current); + for (const item of [...pendingListener]) { + const injected = injectEnvelope(messages, item); + if (injected === messages) continue; + messages = injected; + const index = pendingListener.indexOf(item); + if (index >= 0) pendingListener.splice(index, 1); + releaseItem(item); + } + admitOverflowItem(); + + if (activated && !stopping && (agentActive || desiredState === "busy")) { + try { + await setState("busy"); + } catch { + return messages === event.messages ? undefined : { messages }; + } + try { + const polled = await runCommand([ + "api", + "--adapter", + ADAPTER, + "poll", + id, + "--include-deferred", + ...authArgs(), + ]); + clearCommsFailure("poll"); + if (String(polled).trim()) { + messages = [ + ...messages, + { + role: "custom", + customType: "spt-event", + content: String(polled), + display: false, + attribution: "user", + timestamp: Date.now(), + }, + ]; + } + } catch (error) { + markCommsFailure("poll", "omp-spt could not poll active messages", error); + } } if (messages !== event.messages) return { messages }; }); // [impl->REQ-PARITY-STARTUP-BRIEF] // [impl->REQ-PARITY-TARGETED-HINTS] // [impl->REQ-PARITY-UPDATE-NOTICE] - pi.on("before_agent_start", (event) => { + pi.on("before_agent_start", async (event) => { if (!activated || !id || stopping) return; + desiredState = "busy"; + try { + await setState("busy"); + } catch { + // Local model work proceeds while the state retry loop restores routing truth. + } const additions = []; if (startupBriefPending) { startupBriefPending = false; additions.push(startupBrief(id)); } const hints = promptHints(event.prompt); if (hints.length > 0) additions.push(`OMP SPT targeted hints:\n- ${hints.join("\n- ")}`); if (updateNoticesPending && updateNoticesReady) { updateNoticesPending = false; if (updateNotices.length > 0) { additions.push(`OMP SPT updates:\n- ${updateNotices.join("\n- ")}`); } } firstTurnContextStarted = true; if (additions.length === 0) return; return { systemPrompt: [...(event.systemPrompt ?? []), additions.join("\n\n")], }; }); // [impl->REQ-PARITY-PEER-SHORTFORM] async function dispatchShortforms(assistantOutput) { const shortforms = parsePeerShortforms(assistantOutput); if (shortforms.length === 0 || stopping) return; const deliveries = shortforms.flatMap((shortform) => shortform.targets.map((target) => ({ target, body: shortform.body })), ); await mapWithConcurrency( deliveries, shortformConcurrency, async ({ target, body }) => { if (!id) return `${target}: failed (activate this OMP session first)`; try { const result = await runCommand(["send", target, "--from", id], body, { timeoutMs: shortformCommandTimeoutMs, }); return `${target}: ${firstLine(result) || "sent"}`; } catch (error) { return `${target}: failed (${errorSummary(error)})`; } }, ); if (stopping) return; } // [impl->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function completeTurn(event) { agentActive = false; showIdleTitle(); desiredState = "idle"; if (stopping) return; const messages = event.messages ?? []; const currentTurnAssistant = assistantAfterBaseline(messages, turnAssistantBaseline); - const completed = current; const currentTurnReply = successfulAssistant(currentTurnAssistant) ? messageText(currentTurnAssistant) : ""; - if (current === completed) { - current = undefined; - releaseItem(completed); - } observedAssistantBaseline = captureAssistantBaseline(messages); try { await setState("idle"); - } catch (error) { - if (stopping) return; - await failClosed("omp-spt could not mark the endpoint idle", error); - return; + } catch { + // Local completion wins; the retry loop converges on current idle truth. } await dispatchShortforms(currentTurnReply); - if (!stopping) await dispatchNext(); + if (!stopping) resubmitUnobservedListenerItems(); } pi.on("agent_start", async () => { if (stopping) return; turnCompletionPromise = undefined; turnAssistantBaseline = observedAssistantBaseline; turnContextObserved = false; agentActive = true; showBusyTitle(); desiredState = "busy"; try { await syncDesiredState(); - } catch (error) { - if (!stopping) await failClosed("omp-spt could not mark the endpoint busy", error); + } catch { + // before_agent_start already opened recovery; never terminate local work. } }); - // [impl->REQ-OMP-EXTENSION-CUSTODY] + // [impl->REQ-OMP-CORE-DELIVERY] pi.on("agent_end", (event) => { turnCompletionPromise ??= completeTurn(event); return turnCompletionPromise; }); pi.on("session_stop", async (event) => { turnCompletionPromise ??= completeTurn(event); await turnCompletionPromise; }); pi.on("session_shutdown", async (_event, ctx) => { runtimeCtx ??= ctx; ui?.setStatus("omp-spt", undefined); await shutdownWithinBudget("OMP session shut down before your message could complete"); }); }; } export default createOmpSpt(); diff --git a/docs/CI.md b/docs/CI.md index beedb66..4f0d26a 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,104 +1,104 @@ # CI and release acceptance `omp-spt` uses two verification layers: 1. deterministic gates that run on every change; and 2. release acceptance against a real native OMP endpoint. The first layer is repeatable and does not require a live model session. The second proves the user-visible hosting contract that deterministic tests cannot establish. A deterministic pass is necessary, but it is not release acceptance. ## Deterministic gates Run these from the repository root: ```sh sh tests/ci-gates.sh sh ci/run-gates.sh node tests/omp-extension.mjs ``` -`tests/ci-gates.sh` guards the gate dispatcher itself. `ci/run-gates.sh` performs shell-syntax checks, validates `adapter/omp-spt.toml` against the vendored published schema, checks the native launch and session manifest, tests the fat archive contract, runs a hermetic release-acquisition dry run with fake `gh`/`spt` commands, and runs the `omp-spt` helper's Rust tests and release build. It also runs `traceable-reqs check` when the command is installed. `tests/omp-extension.mjs` exercises extension delivery, serialized custody, correlated outcomes, busy/idle transitions, listener failure handling, immutable session binding, and shutdown. +`tests/ci-gates.sh` guards the gate dispatcher itself. `ci/run-gates.sh` performs shell-syntax checks, validates `adapter/omp-spt.toml` against the vendored published schema, checks the native launch and session manifest, tests the fat archive contract, runs a hermetic release-acquisition dry run with fake `gh`/`spt` commands, and runs the `omp-spt` helper's Rust tests and release build. It also runs `traceable-reqs check` when the command is installed. `tests/omp-extension.mjs` exercises listener and active-poll delivery, core custody ownership, explicit outbound messaging, busy/idle transitions, nonfatal communications recovery, immutable session binding, and shutdown. - - + + These are binary pass/fail checks. A release run must treat any `SKIP` caused by a missing interpreter, Rust toolchain, platform binary, or `traceable-reqs` installation as an incomplete gate, provision the dependency, and rerun. The archive test may intentionally prove that the packer refuses missing target binaries, but a release still needs all three target builds before packaging. The live acquisition integration intentionally skips unless `OMP_SPT_ACCEPTANCE=1`; after opt-in, missing release version, target, tools, or GitHub authentication are failures. Its hermetic central-gate test opts in and exercises those fail-closed paths without network access. ## Native OMP release acceptance ADR-0013 sets the release boundary. The supported helper targets are **x86_64 Windows** (`x86_64-pc-windows-msvc`), **x86_64 GNU Linux** (`x86_64-unknown-linux-gnu`), and a static **x86_64 musl Linux** compatibility tier (`x86_64-unknown-linux-musl`). The asset has no macOS or Arm64 payload. Acceptance must use the exact tagged candidate `adapter.spt`, a real `omp` installation, the native packaged extension, and disposable endpoint identities. Complete the full acceptance sequence independently on all three targets before promotion. Do not reuse an operator's long-lived endpoint id. Install the tagged candidate on each acceptance host and verify its manifest version: ```sh spt adapter add --release BigscreenVR/omp-spt --tag vX.Y.Z spt adapter version omp-spt ``` The version command must print exactly `X.Y.Z`. Then record target-specific evidence for every item below; a pass on one target cannot stand in for either of the others. ### musl selected-helper boundary Under [ADR-0019](adr/0019-musl-acceptance-follows-selected-helper.md), the musl record must come from a disposable OMP-capable host where spt-core selects `x86_64-unknown-linux-musl/omp-spt` from the tagged archive and the installed helper digest exactly matches that member. The base distribution may use glibc; helper selection and native execution, not distribution branding, define this adapter seam. Record `target: x86_64-unknown-linux-musl`, native `spt --version` and `omp --version` output, tagged acquisition, and the same endpoint evidence required below. This is a helper compatibility tier, not a claim of generic Alpine support. Manually executing the static helper does not qualify, and neither does a container where real OMP is absent. ### 1. Fresh bringup and attached TUI ```sh spt endpoint run --adapter omp-spt --id omp-spt-accept-fresh --create ``` The default action attaches the terminal. Pass only if the broker-held terminal displays the real interactive OMP TUI, the packaged `adapter/strings/omp-spt.mjs` extension binds `omp-spt-accept-fresh`, and the endpoint becomes reachable. Do not add a background-start flag: the attached native TUI is part of this proof. ### 2. Same-node message, turn, explicit reply, and state From a second terminal on the same node: ```sh printf 'After reading this message, explicitly send exactly OMP-SPT-ACCEPTED to the sender using spt send or the peer-message shortform.' | spt ring omp-spt-accept-fresh --timeout 120 ``` Pass only if: - the delivery becomes one ordinary OMP user turn containing the sender stub and complete SPT event context; - the TUI visibly runs that turn; - `spt ring` prints `OMP-SPT-ACCEPTED`, proving the agent used an explicit outbound messaging action; - ordinary assistant prose visible in the TUI is not forwarded to the sender; - `spt endpoint list --json` shows the endpoint move from idle to busy for the turn and back to idle after completion; and - the OMP session name is ` @ (/)`, the idle window title begins with `○`, and the busy turn visibly cycles braille spinner glyphs before returning to `○`. This is an end-to-end delivery and explicit-messaging check. Turn failure, submission failure, and shutdown must remain local rather than synthesizing an outbound peer message. ### 3. Immutable in-TUI binding and native resume Record the bound OMP session id. While the endpoint is running, attempt OMP's in-TUI new-session and resume/switch actions. Both must be blocked with an `omp-spt` warning, and the bound session id must remain unchanged. End the endpoint gracefully, then bind a new disposable endpoint explicitly to the recorded OMP session: ```sh spt endpoint shutdown omp-spt-accept-fresh spt endpoint run --adapter omp-spt --id omp-spt-accept-resume --resume ``` Pass only if this launches OMP's native resume path into an attached TUI, preserves the prior transcript, and binds the new endpoint to the requested session. In-TUI switching stays blocked after resume. Native resume happens at endpoint launch, never by moving a running endpoint to another session. ### 4. ReadyAgent and LiveAgent Exercise both hostable roles through spt-core's current ReadyAgent and LiveAgent flows: - **ReadyAgent:** the native OMP endpoint binds, listens, receives a same-node request, sends an explicit reply, keeps ordinary assistant output local, and shuts down without requiring a daemon-driven Psyche turn. - **LiveAgent:** the same native OMP hosting path remains attached and reachable while the daemon drives the manifest's bounded `psyche-omp` role; a Psyche event completes successfully, and normal inbound delivery plus explicit outbound messaging still works afterward. diff --git a/docs/KNOWN-HAZARDS.md b/docs/KNOWN-HAZARDS.md index 05c0c93..62ec82b 100644 --- a/docs/KNOWN-HAZARDS.md +++ b/docs/KNOWN-HAZARDS.md @@ -1,76 +1,75 @@ # Known hazards This is the OMP adapter's conformance checklist. Each entry states a failure mode, the invariant that prevents it, the evidence boundary, and its source. An invariant is not covered merely because it is described here: the active requirement must point at production behavior and a focused test. -## 1. Delivery custody can be lost or assistant output can leak to a peer +## 1. Adapter-side custody duplicates core or assistant output leaks to a peer - + -- **Failure:** Two SPT deliveries overlap, the active sender is overwritten, or - ordinary assistant output is implicitly forwarded to a peer. -- **Invariant:** The extension serializes deliveries from receipt through their - OMP turn and releases each custody record on completion. It never converts - assistant output, turn failure, submission failure, or shutdown text into an - outbound peer message. Outbound messaging occurs only through explicit +- **Failure:** The extension duplicates spt-core's spool, merges listener and + poll chronology, or implicitly forwards ordinary assistant output to a peer. +- **Invariant:** spt-core retains durable custody. Listener events surface as + they arrive through OMP's native message rail; `api poll --include-deferred` + surfaces independently at each active context boundary. The extension never + converts assistant output, turn failure, submission failure, or shutdown text + into an outbound peer message. Outbound messaging occurs only through explicit `spt send` use or the `@<…@>` shortform. -- **Mapping / notes:** The native OMP extension owns receipt, queueing, - prompt-flow submission, and turn completion. An active-turn delivery enters - OMP through `sendUserMessage`'s native steering queue, then the context hook - expands its inert sender stub into the full envelope at the next safe model - boundary. Idle delivery starts the same native prompt flow immediately. Peer - content is never submitted as a local slash command. -- **cite:** ADR-0010 and the OMP extension lifecycle contract. +- **Mapping / notes:** The native OMP extension expands a listener event's inert + sender stub into its complete envelope at the next safe model boundary. + Poll stdout enters model-only context without a custom TUI panel. The adapter + neither persists a second receipt ledger nor invents cross-channel ordering. +- **cite:** ADR-0010, ADR-0017, and the published spt-core harness contract. -## 2. A dead listener leaves a healthy-looking but unreachable endpoint +## 2. A dead communication path stops local work or looks healthy - + -- **Failure:** The SPT listener exits while OMP remains open and bound. If the - extension retries forever or merely logs the exit, the endpoint can stay - advertised as online while no message can reach it. -- **Invariant:** Unexpected listener exit triggers only a finite, deterministic - restart schedule. A successful restart resumes delivery with custody intact. - Exhausting the schedule performs SPT session-end and shuts down OMP loudly. - There is no infinite restart loop and no healthy advertisement after the - delivery path is gone. -- **Mapping / notes:** Retry timers are extension-owned and cancellable during - normal shutdown. The exhausted path uses the same serialized teardown seam as - an explicit endpoint stop. -- **cite:** ADR-0010; field issue HIGH-2/HIGH-4 successor invariant. +- **Failure:** Listener, activity publication, or polling fails while OMP is + bound. Shutting down OMP loses local work; showing only the normal identity + makes a constrained-delivery outage invisible. +- **Invariant:** An established endpoint keeps OMP running. Listener recovery + retries indefinitely with capped backoff; state recovery converges on current + OMP truth; polling retries only at a real context boundary. The cyan identity + remains visible with warning-styled ` · comms recovering...` until every + failing component recovers. +- **Mapping / notes:** The listener relay itself disappears from spt-core when + its child exits, so retrying does not preserve a false ONLINE claim. Initial + bind failure remains an activation failure because no endpoint was established. +- **cite:** ADR-0010 and the published state/poll/listen contract. ## 3. In-TUI session switching breaks endpoint identity - **Failure:** A bound endpoint switches, branches, creates, or resumes another OMP session from inside the TUI. The stable SPT endpoint id then points at a different transcript than queued messages, replies, history, digest, and durable mind state expect. - **Invariant:** One endpoint owns exactly one OMP session for its lifetime. The extension blocks every in-TUI action that would change that session. Selecting another session requires stopping the endpoint and relaunching it with the explicit native-resume role, producing a new deliberate bind. - **Mapping / notes:** This is stricter than merely rebinding on a session event. A rebind would preserve liveness while violating custody and history identity, so it is not an allowed recovery. - **cite:** ADR-0011. ## 4. The `omp` basename resolves to the wrong executable - **Failure:** Another program owns the bare `omp` token, or an adapter helper is advertised as a host binary. The broker can launch or bind the wrong process, while process listings conceal which application actually owns the endpoint. - **Invariant:** The manifest advertises only the genuine OMP host basename. The native launch shim resolves and validates Oh My Pi before launch, rejects collisions loudly, and then replaces itself or inherits the terminal unchanged so native OMP remains the PTY owner. - **Mapping / notes:** `omp-spt` is the adapter helper/release binary, never a hosted-harness match key. Fresh and resume launch use the same resolver. - **cite:** ADR-0009 and the retired bridge incident's executable-collision finding. diff --git a/docs/adr/0010-native-delivery-self-heals-or-closes.md b/docs/adr/0010-native-delivery-self-heals-or-closes.md deleted file mode 100644 index 8c4a624..0000000 --- a/docs/adr/0010-native-delivery-self-heals-or-closes.md +++ /dev/null @@ -1,10 +0,0 @@ -# Native delivery self-heals or closes the endpoint - -Status: accepted (2026-07-14) - -The OMP SPT extension owns delivery custody from receipt through one serialized OMP turn, then releases it without creating an outbound message. Assistant output, submission errors, failed turns, and shutdown text remain local; a model or operator sends to a peer only through explicit `spt send` use or the `@<…@>` shortform. If the SPT listener exits, the extension retries with bounded backoff. Exhausting that budget ends the SPT session and shuts down the hosted endpoint loudly, preventing a dead delivery path from remaining advertised as healthy. - -Normal peer delivery uses the native OMP message-stub contract: -the extension opens the turn with `` and supplies the complete -SPT event envelope as context for that same turn. Peer text therefore remains -opaque message content and cannot accidentally invoke an OMP slash command. diff --git a/docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md b/docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md index 9832ab7..e2f0d28 100644 --- a/docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md +++ b/docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md @@ -1,7 +1,7 @@ --- status: accepted --- # Active-turn delivery uses safe boundaries -A peer message accepted during an active OMP turn remains in the extension's ordered custody and is injected at the first proven boundary before the next tool or model continuation; if the turn exposes no injectable boundary, the message becomes ordinary next-turn delivery. Always waiting for the next turn was rejected because it leaves live agents unreachable during long work, while arbitrary event injection and forced interruption were rejected because they make model context, tool intent, and exactly-once correlation unpredictable. +Peer messages made available during an active OMP turn use their published channel directly. A live-listener arrival is surfaced immediately through the turn's native steering rail; the steer never wakes the endpoint or starts a nested turn, and the context hook expands its inert sender stub into the complete envelope at the first proven boundary before the next tool or model continuation. Separately, spt-core retains `--active-only` messages in its durable spool until an active context boundary runs authenticated `api poll --include-deferred` and surfaces that command's stdout at that boundary. The adapter does not merge the listener and poll channels into a synthetic ordered queue or infer cross-channel chronology: the listener surfaces messages as it receives them, and poll surfaces messages when it polls them. Listener arrivals during context assembly steer the next boundary. Enqueueing a steer is not reported as model-context delivery; returning the complete envelope from the context hook is the published adapter-contract transfer. If a turn ends before that boundary, unpolled active-only messages remain in spt-core custody while listener arrivals cross to ordinary idle delivery from extension memory. The published `poll` and listener surfaces are destructive drains without a receipt or acknowledgment operation, so the adapter does not duplicate spt-core's spool with a second durable custody system; process failure after core emission and before OMP consumption remains a public-contract limitation. Always waiting for the next turn was rejected because it leaves live agents unreachable during long work. Extension-memory-only context injection without an explicit steer was rejected because it can persist history after an ACP request is already assembled without guaranteeing another model continuation. Arbitrary event injection and forced interruption were rejected because they make model context and tool intent unpredictable. diff --git a/tests/manifest-shortcut.sh b/tests/manifest-shortcut.sh index 011f670..dc4fde8 100644 --- a/tests/manifest-shortcut.sh +++ b/tests/manifest-shortcut.sh @@ -54,163 +54,169 @@ signoff_dir=$(field_of session '^[[:space:]]*signoff_dir[[:space:]]*=') case "$commune_dir" in 'commune_dir = ".spt"') echo "ok commune_dir = .spt" ;; *) echo "FAIL commune_dir must be .spt: $commune_dir"; fail=1 ;; esac case "$signoff_dir" in 'signoff_dir = ".spt"') echo "ok signoff_dir = .spt" ;; *) echo "FAIL signoff_dir must be .spt: $signoff_dir"; fail=1 ;; esac if [ -f "$EXTENSION" ]; then echo "ok packaged omp-spt extension exists"; else echo "FAIL packaged omp-spt extension missing"; fail=1; fi if command -v node >/dev/null 2>&1; then NODE=node elif command -v node.exe >/dev/null 2>&1; then NODE=node.exe else echo "FAIL node is required for the OMP extension unit test"; fail=1; NODE=: fi NODE_TEST="$ROOT/tests/omp-extension.mjs" case "$NODE" in *.exe) NODE_TEST=$(wslpath -w "$NODE_TEST") ;; esac "$NODE" "$NODE_TEST" || fail=1 # ── daemon seams use the project-local snapshot, never rejected read-env placeholders ───────────── digest_cmd=$(field_of digest '^[[:space:]]*extractor[[:space:]]*=') history_cmd=$(field_of history '^[[:space:]]*fetcher[[:space:]]*=') echo_cmd=$(field_of session.echo_commune '^[[:space:]]*command[[:space:]]*=') case "$digest_cmd" in 'extractor = "omp-spt digest-omp --session {session_id} --project-dir \"{cwd}\""') echo "ok [digest].extractor scans project snapshots by session" ;; *) echo "FAIL [digest].extractor is not project-snapshot-aware: $digest_cmd"; fail=1 ;; esac case "$history_cmd" in 'fetcher = "omp-spt history-omp --session {session_id} --project-dir \"{cwd}\""') echo "ok [history].fetcher scans project snapshots by session" ;; *) echo "FAIL [history].fetcher is not project-snapshot-aware: $history_cmd"; fail=1 ;; esac case "$echo_cmd" in 'command = "omp-spt echo-commune-omp --id {id} --session-id {session_id}"') echo "ok [session.echo_commune] recovers the endpoint snapshot by id" ;; *) echo "FAIL echo_commune command is not endpoint-snapshot-aware: $echo_cmd"; fail=1 ;; esac if grep -q -- '--captured-env' "$MANIFEST"; then echo "FAIL manifest still passes retired --captured-env argv" fail=1 else echo "ok no retired --captured-env argv remains" fi for var in OMP_PROFILE PI_PROFILE PI_CODING_AGENT_DIR PI_CONFIG_DIR XDG_DATA_HOME HOME USERPROFILE OMP_SPT_OMP_BIN; do if grep -Eq "\{$var\}|^\[env\.$var\]" "$MANIFEST"; then echo "FAIL manifest still references registration-rejected read-env selector $var" fail=1 fi done if [ "$fail" -eq 0 ]; then echo "ok rejected read-env declarations/placeholders are absent"; fi # Current spt-core only fills the published Psyche custody keys for Psyche roles. Profile/model/auth # state comes from the endpoint snapshot, not from extra manifest substitutions. npsy=$(grep -cE '^[[:space:]]*command[[:space:]]*=[[:space:]]*"omp-spt psyche-omp --id \{id\} --session-id \{session_id\} --psyche-context-file \{psyche_context_file\}"' "$MANIFEST") if [ "$npsy" -eq 2 ]; then echo "ok both psyche roles use the public Psyche key catalog"; else echo "FAIL expected 2 valid psyche-omp role commands, found $npsy"; fail=1; fi for role in session.psyche_init session.psyche_resume; do role_keys=$(field_of "$role" '^[[:space:]]*keys[[:space:]]*=') role_cmd=$(field_of "$role" '^[[:space:]]*command[[:space:]]*=') case "$role_keys:$role_cmd" in *OMP_PROFILE*|*PI_PROFILE*|*PI_CODING_AGENT_DIR*|*PI_CONFIG_DIR*|*XDG_DATA_HOME*|*HOME*|*USERPROFILE*|*OMP_SPT_OMP_BIN*) echo "FAIL [$role] references an unsupported read-env substitution"; fail=1 ;; *) echo "ok [$role] avoids unsupported read-env substitutions" ;; esac done # The retired resident key must stay GONE from both psyche roles. if grep -q 'psyche_prompt' "$MANIFEST"; then echo "FAIL a psyche role references the retired {psyche_prompt}"; fail=1; else echo "ok no {psyche_prompt} anywhere (event rides stdin)"; fi # [unit->REQ-PSYCHE-EPHEMERAL-SHIM] — identity-env scrub on both Psyche roles. for role in session.psyche_init session.psyche_resume; do scrub=$(field_of "$role" '^[[:space:]]*env_remove[[:space:]]*=') if [ -z "$scrub" ]; then echo "FAIL [$role] has no env_remove (identity-env scrub missing)"; fail=1; else case "$scrub" in *'"OWL_SESSION_ID"'*'"SPT_AGENT_ID"'*|*'"SPT_AGENT_ID"'*'"OWL_SESSION_ID"'*) echo "ok [$role] env_remove scrubs OWL_SESSION_ID + SPT_AGENT_ID" ;; *) echo "FAIL $role env_remove misses an identity var: $scrub"; fail=1 ;; esac fi done rz_detach=$(field_of session.psyche_resume '^[[:space:]]*detach[[:space:]]*=') case "$rz_detach" in *false*) echo "ok psyche_resume is captured (detach=false)" ;; *) echo "FAIL psyche_resume must be detach=false, got: [$rz_detach]"; fail=1 ;; esac rz_keys=$(field_of session.psyche_resume '^[[:space:]]*keys[[:space:]]*=') case "$rz_keys" in *'"psyche_context_file"'*) echo "ok psyche_resume keys include psyche_context_file" ;; *) echo "FAIL psyche_resume keys missing psyche_context_file: [$rz_keys]"; fail=1 ;; esac # ── [update] — the gh_release self-update seam points at the fork ───────────────────────────────── if grep -Eq '^[[:space:]]*repo[[:space:]]*=[[:space:]]*"BigscreenVR/omp-spt"' "$MANIFEST"; then echo 'ok [update].repo = "BigscreenVR/omp-spt"'; else echo "FAIL [update].repo is not BigscreenVR/omp-spt"; fail=1; fi if grep -Eq '^[[:space:]]*repo[[:space:]]*=[[:space:]]*"SaberMage/' "$MANIFEST"; then echo "FAIL an active repo assignment still points at SaberMage/*"; fail=1; else echo "ok no active SaberMage repo assignment"; fi if grep -Eq '^[[:space:]]*transport[[:space:]]*=[[:space:]]*"gh"' "$MANIFEST"; then echo 'ok [update].transport = "gh"'; else echo "FAIL [update] transport must use gh for auth-capable release fetch"; fail=1; fi if grep -Eq '^[[:space:]]*message[[:space:]]*=' "$MANIFEST"; then echo "ok [update].message present"; else echo "FAIL [update] has no message field"; fail=1; fi if grep -Eq 'endpoint run --adapter omp-spt --id --create --start' "$MANIFEST"; then echo "FAIL fresh-endpoint update notice still uses --start (hides the harness PTY)"; fail=1; else echo "ok fresh-endpoint update notice omits --start (attach-default PTY)"; fi +# [unit->REQ-OMP-CORE-DELIVERY] +inject_activity=$(field_of inject '^[[:space:]]*activity[[:space:]]*=') +case "$inject_activity" in + 'activity = ["hook"]') echo "ok [inject] routes idle and active custody through OMP hooks" ;; + *) echo "FAIL [inject].activity must be exactly [\"hook\"]: [$inject_activity]"; fail=1 ;; +esac + # ── retired foreign-harness seams stay absent ──────────────────────────────────────────────────── # [unit->REQ-OMP-NATIVE-TUI] for stale in \ '^\[update\.post\]' \ '^\[hooks\.' \ - '^\[inject\]' \ '^\[message-idle-translation-binary\]' \ '^\[env\.SPT_INJECT_VERIFY_ECHO\]' \ '^\[env\.CLAUDE_CONFIG_DIR\]' \ '^[[:space:]]*hook_cmd[[:space:]]*=' \ '^[[:space:]]*command[[:space:]]*=.* translate'; do if grep -Eq "$stale" "$MANIFEST"; then echo "FAIL retired manifest surface remains: $stale" fail=1 fi done -if [ "$fail" -eq 0 ]; then echo "ok retired update/hook/inject/translation/env surfaces absent"; fi +if [ "$fail" -eq 0 ]; then echo "ok retired update/hook/translation/env surfaces absent"; fi # OMP model routing is native; the adapter ships no foreign profile overlay. if grep -Eq '^\[profiles\.' "$MANIFEST"; then echo "FAIL a shipped [profiles.*] table lingers"; fail=1; else echo "ok no shipped profile overlays"; fi # [int->REQ-DIST-MANIFEST-SCHEMA] — real binary validation catches role-key rules the JSON schema # cannot. Local developer machines may skip when spt is absent; a release gate must set # OMP_SPT_RELEASE_MODE=1, which makes absence a hard failure instead of a false green. if command -v spt >/dev/null 2>&1; then REG_TMP="${TMPDIR:-/tmp}/omp-spt-manifest-registration-$$" rm -rf "$REG_TMP" mkdir -p "$REG_TMP/localappdata" "$REG_TMP/canonical/strings" REG_LOCAL="$REG_TMP/localappdata" REG_MANIFEST="$MANIFEST" REG_CANONICAL="$REG_TMP/canonical/manifest.toml" case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) REG_LOCAL=$(cygpath -w "$REG_LOCAL") REG_MANIFEST=$(cygpath -w "$REG_MANIFEST") REG_CANONICAL=$(cygpath -w "$REG_CANONICAL") ;; *) case "$(command -v spt)" in *.exe) if command -v wslpath >/dev/null 2>&1; then REG_LOCAL=$(wslpath -w "$REG_LOCAL") REG_MANIFEST=$(wslpath -w "$REG_MANIFEST") REG_CANONICAL=$(wslpath -w "$REG_CANONICAL") fi ;; esac ;; esac if register_output=$(LOCALAPPDATA="$REG_LOCAL" spt adapter add "$REG_MANIFEST" 2>&1); then echo "ok real isolated spt adapter add accepts adapter/omp-spt.toml" else echo "FAIL real isolated spt adapter add rejected manifest: $register_output" fail=1 fi cp "$MANIFEST" "$REG_TMP/canonical/manifest.toml" cp "$EXTENSION" "$REG_TMP/canonical/strings/omp-spt.mjs" expected_version=$(field_of adapter '^[[:space:]]*version[[:space:]]*=' | cut -d'"' -f2) if canonical_output=$(LOCALAPPDATA="$REG_LOCAL" spt adapter add "$REG_CANONICAL" 2>&1) \ && registered_version=$(LOCALAPPDATA="$REG_LOCAL" spt adapter version omp-spt 2>&1); then registered_version=$(printf '%s' "$registered_version" | tr -d '\r\n') if [ "$registered_version" = "$expected_version" ]; then echo "ok real isolated spt reports omp-spt $expected_version" else echo "FAIL real isolated spt reports '$registered_version' (want $expected_version)" fail=1 fi else echo "FAIL real isolated spt version check failed: $canonical_output ${registered_version:-}" fail=1 fi rm -rf "$REG_TMP" else case "${OMP_SPT_RELEASE_MODE:-0}:${GITHUB_REF_TYPE:-}" in 1:*|*:tag) echo "FAIL spt is required for clean registration in release mode" fail=1 ;; *) echo "SKIP real spt clean-registration check (spt not on PATH)" ;; esac fi [ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; } diff --git a/tests/omp-extension.mjs b/tests/omp-extension.mjs index d9c242f..ab2c225 100644 --- a/tests/omp-extension.mjs +++ b/tests/omp-extension.mjs @@ -361,890 +361,837 @@ async function testParsing() { '\nhello\n', ); const partialEnvelope = 'hello
wo'; const partial = drainEvents(`noise${partialEnvelope}`); assert.deepEqual(partial.events, []); assert.equal(partial.rest, partialEnvelope); const envelope = `${partial.rest}rld
`; const complete = drainEvents(`${envelope}skip`); assert.deepEqual(complete.events, [ { from: "doyle", body: "hello\nworld", envelope }, ]); assert.equal(complete.rest, ""); const literalEventBody = 'No inbound containing the message has surfaced.'; assert.deepEqual(drainEvents(literalEventBody).events, [ { from: "hertz", body: "No inbound containing the message has surfaced.", envelope: literalEventBody, }, ]); const nestedEventBody = 'quoted valid tail'; assert.deepEqual(drainEvents(nestedEventBody).events, [ { from: "a", body: 'quoted valid tail', envelope: nestedEventBody, }, ]); assert.match( drainEvents('missing sender').error.message, /missing EVENT from/, ); assert.match( drainEvents('bad attrs').error.message, /malformed EVENT attributes/, ); assert.match( drainEvents('0123456789', { maxFrameChars: 32, }).error.message, /EVENT frame exceeded/, ); } // [unit->REQ-OMP-SESSION-TITLES] async function testEndpointSessionNameAndAnimatedWindowTitle() { const harness = createHarness({ id: "emphasys", node: "HFENDULEAM", project: "omp-spt", }); await harness.emit("session_start"); assert.deepEqual(harness.sessionNames, ["emphasys @ HFENDULEAM (omp-spt/)"]); assert.deepEqual(harness.titles, ["○ emphasys @ HFENDULEAM (omp-spt/)"]); assert.equal( harness.statuses.at(-1).text, "emphasys @ HFENDULEAM (omp-spt/)", ); await harness.emit("agent_start"); assert.equal(harness.titles.at(-1), "⣾ emphasys @ HFENDULEAM (omp-spt/)"); assert.equal(harness.intervals.length, 1); assert.equal(harness.intervals[0].delay, 500); harness.intervals[0].fn(); assert.equal(harness.titles.at(-1), "⣽ emphasys @ HFENDULEAM (omp-spt/)"); await harness.emit("agent_end", { messages: [] }); assert.equal(harness.intervals[0].active, false); assert.equal(harness.titles.at(-1), "○ emphasys @ HFENDULEAM (omp-spt/)"); await harness.emit("session_shutdown"); assert.equal(harness.intervals[0].active, false); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] +// [unit->REQ-OMP-CORE-DELIVERY] // [unit->REQ-OMP-SESSION-IMMUTABLE] // [unit->REQ-OMP-MESSAGE-CONTEXT] // [unit->REQ-OMP-NATIVE-TUI] async function testLifecycleCustodyAndContext() { const harness = createHarness({ subnet: "mesh-a" }); assert.deepEqual([...harness.handlers.keys()], [ "session_start", "session_before_switch", "session_before_branch", "context", "before_agent_start", "agent_start", "agent_end", "session_stop", "session_shutdown", ]); await harness.emit("session_start"); - assert.deepEqual(harness.calls[0], { - args: [ - "api", - "--adapter", - "omp-spt", - "bind", - "omp-agent", - "--set-session-id", - "session-1", - "--subnet", - "mesh-a", - ], - input: undefined, - }); - assert.deepEqual(harness.calls[1].args, [ + assert.deepEqual(harness.calls[0].args.slice(0, 7), [ "api", "--adapter", "omp-spt", - "state", - "idle", + "bind", "omp-agent", - "--token", - "token-123", + "--set-session-id", + "session-1", ]); - assert.equal(harness.children.length, 1); - assert.equal(harness.children[0].binary, "spt-test"); - assert.deepEqual(harness.children[0].args, [ + assert.equal(stateCalls(harness).at(-1).args[4], "idle"); + assert.deepEqual(harness.children[0].args.slice(0, 7), [ "api", "--adapter", "omp-spt", "listen", "omp-agent", "--session-id", "session-1", - "--subnet", - "mesh-a", ]); for (const reason of ["new", "resume", "fork", "handoff"]) { assert.deepEqual(await harness.emit("session_before_switch", { reason }), { cancel: true }); - assert.ok( - harness.notifications.some(({ message }) => - message.includes(`${reason} session switch`), - ), - ); } assert.deepEqual(await harness.emit("session_before_branch"), { cancel: true }); - assert.ok( - harness.notifications.some(({ message }) => message.includes("session branch")), - ); const aliceEnvelope = 'hello<world
line
'; const bobEnvelope = 'second'; harness.children[0].stdout.emit("data", `${aliceEnvelope}${bobEnvelope}`); await flush(); - assert.deepEqual(harness.submitted, ['']); - assert.deepEqual(harness.submittedDeliveries, [undefined]); - assert.deepEqual(harness.sentMessages, []); assert.deepEqual( - stateCalls(harness).map((call) => call.args[4]), - ["idle", "busy"], + harness.submitted, + ['', ''], + "listener events surface independently as they arrive", ); - const originalMessages = [{ role: "user", content: '' }]; - assert.deepEqual(await harness.emit("context", { messages: originalMessages }), { - messages: [ - { - role: "user", - content: `\n\n${formatInboundEnvelope(aliceEnvelope)}`, - }, - ], - }); - + await harness.emit("before_agent_start", { prompt: "listener wake", systemPrompt: [] }); await harness.emit("agent_start"); - assert.deepEqual( - stateCalls(harness).map((call) => call.args[4]), - ["idle", "busy"], - "agent_start must not duplicate the already-honest busy transition", - ); - const aliceReply = assistantMessage([{ type: "text", text: "alice reply" }]); - await harness.emit("agent_end", { + const boundary = await harness.emit("context", { messages: [ { role: "user", content: '' }, - aliceReply, - ], - }); - assert.deepEqual(harness.clock.delays(), []); - assert.deepEqual(harness.submitted, ['', '']); - assert.deepEqual(harness.submittedDeliveries, [undefined, undefined]); - await harness.emit("agent_start"); - await harness.emit("agent_end", { - messages: [ - aliceReply, { role: "user", content: '' }, ], }); - - const outcomes = commandCalls(harness, "send"); - assert.deepEqual( - outcomes, - [], - "ordinary assistant output must never be forwarded to a peer", + assert.equal( + boundary.messages[0].content, + `\n\n${formatInboundEnvelope(aliceEnvelope)}`, ); + assert.equal( + boundary.messages[1].content, + `\n\n${formatInboundEnvelope(bobEnvelope)}`, + ); + assert.equal(harness.calls.filter((call) => call.args[3] === "poll").length, 1); + + await harness.emit("agent_end", { + messages: [...boundary.messages, assistantMessage("local result")], + }); + assert.deepEqual(commandCalls(harness, "send"), []); assert.deepEqual( stateCalls(harness).map((call) => call.args[4]), - ["idle", "busy", "idle", "busy", "idle"], + ["idle", "busy", "idle"], ); - for (const call of [...stateCalls(harness), ...harness.calls.filter((candidate) => candidate.args[3] === "session-end")]) { - assert.deepEqual(call.args.slice(-2), ["--token", "token-123"]); - } - await harness.emit("session_shutdown"); - const ended = harness.calls.filter((call) => call.args[3] === "session-end"); - assert.equal(ended.length, 1); - assert.deepEqual(ended[0].args.slice(-2), ["--token", "token-123"]); + assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.equal(harness.children[0].kills, 1); - assert.deepEqual(harness.clock.delays(), []); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] +// [unit->REQ-OMP-CORE-DELIVERY] // [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function testLocalAssistantOutputDoesNotReplyToPeer() { const harness = createHarness(); await harness.emit("session_start"); harness.children[0].stdout.emit( "data", 'peer reply', ); await flush(); await harness.emit("context", { messages: [{ role: "user", content: '' }], }); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ { role: "user", content: '' }, { role: "user", content: "local user interjection" }, assistantMessage("answer intended for the local user"), ], }); assert.deepEqual( commandCalls(harness, "send"), [], "a local interjection must not be correlated back to the peer", ); assert.deepEqual(harness.clock.delays(), []); assert.deepEqual( harness.submitted, [''], "the received peer delivery completes without an implicit response", ); await harness.emit("session_shutdown"); } async function testDeferredBindLifecycleSerialization() { const busyBind = deferred(); const busyHarness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "bind") return busyBind.promise; }, }); const startingBusy = busyHarness.emit("session_start"); await flush(); const becomingBusy = busyHarness.emit("agent_start"); await flush(); assert.deepEqual(stateCalls(busyHarness), []); assert.equal(busyHarness.children.length, 0); busyBind.resolve("BOUND endpoint token=token-busy"); await Promise.all([startingBusy, becomingBusy]); assert.deepEqual( stateCalls(busyHarness).map((call) => call.args[4]), - ["busy"], - "agent_start before bind completion must suppress the stale idle publication", + ["busy", "busy"], + "lifecycle callbacks publish current truth directly without replaying stale idle state", ); assert.equal(busyHarness.children.length, 1); await busyHarness.emit("session_shutdown"); assert.deepEqual(busyHarness.clock.delays(), []); const shutdownBind = deferred(); const shutdownHarness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "bind") return shutdownBind.promise; }, }); const startingShutdown = shutdownHarness.emit("session_start"); await flush(); const shuttingDown = shutdownHarness.emit("session_shutdown"); await flush(); assert.equal(shutdownHarness.children.length, 0); assert.equal( shutdownHarness.calls.filter((call) => call.args[3] === "session-end").length, 0, ); shutdownBind.resolve("BOUND endpoint token=token-shutdown"); await Promise.all([startingShutdown, shuttingDown]); assert.deepEqual(stateCalls(shutdownHarness), []); assert.equal(shutdownHarness.children.length, 0); assert.equal( shutdownHarness.calls.filter((call) => call.args[3] === "session-end").length, 1, ); assert.ok( !shutdownHarness.statuses.some(({ text }) => text === "spt:omp-agent"), "bind completion after shutdown must not restore live status", ); assert.deepEqual(shutdownHarness.clock.delays(), []); const hungBindHarness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "bind") return new Promise(() => {}); }, }); const hungStart = hungBindHarness.emit("session_start"); await flush(); const hungShutdown = hungBindHarness.emit("session_shutdown"); await flush(); assert.deepEqual(hungBindHarness.clock.delays().sort((a, b) => a - b), [300, 1_800]); await hungBindHarness.clock.runNext(300); await Promise.all([hungStart, hungShutdown]); assert.equal(hungBindHarness.children.length, 0); assert.equal( hungBindHarness.calls.filter((call) => call.args[3] === "session-end").length, 0, "session-end cannot run without a completed bind token", ); assert.deepEqual(hungBindHarness.clock.delays(), []); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] +// [unit->REQ-OMP-COMMS-RECOVERY] +async function testStateReconciliationUsesLatestActivity() { + const busyState = deferred(); + let heldBusy = false; + const harness = createHarness({ + onRun(call) { + if ( + !heldBusy && + call.args[0] === "api" && + call.args[3] === "state" && + call.args[4] === "busy" + ) { + heldBusy = true; + return busyState.promise; + } + }, + }); + await harness.emit("session_start"); + const starting = harness.emit("agent_start"); + await flush(); + await harness.emit("agent_end", { messages: [assistantMessage("done")] }); + busyState.resolve(""); + await starting; + + assert.deepEqual(harness.clock.delays(), [5]); + assert.match(harness.statuses.at(-1).text, /comms recovering/); + await harness.clock.runNext(5); + assert.deepEqual( + stateCalls(harness).map((call) => call.args[4]), + ["idle", "busy", "idle"], + "a late busy completion is corrected to the latest idle truth", + ); + assert.doesNotMatch(harness.statuses.at(-1).text, /comms recovering/); + await harness.emit("session_shutdown"); +} + +// [unit->REQ-OMP-CORE-DELIVERY] async function testSubmissionFailureAdvancesQueue() { const harness = createHarness({ onSubmit(content) { if (content === '') { throw new Error("OMP prompt flow rejected input"); } }, }); await harness.emit("session_start"); harness.children[0].stdout.emit( "data", 'onetwo', ); await flush(); assert.deepEqual( commandCalls(harness, "send"), [], "a rejected local submission must not message the peer implicitly", ); - assert.deepEqual(harness.clock.delays(), [0]); - - await harness.clock.runNext(0); + assert.deepEqual(harness.clock.delays(), []); assert.deepEqual(harness.submitted, ['', '']); + assert.ok( + harness.errors.some(({ message }) => message.includes("could not submit your message")), + ); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ { role: "user", content: '' }, assistantMessage("next reply"), ], }); assert.deepEqual( commandCalls(harness, "send"), [], "assistant output for the next delivery must remain local", ); await harness.emit("session_shutdown"); assert.deepEqual(harness.clock.delays(), []); } +// [unit->REQ-OMP-COMMS-RECOVERY] async function testFailedIdleRecoveryFailsClosed() { let idleCalls = 0; const harness = createHarness({ - onSubmit() { - throw new Error("OMP prompt flow rejected input"); - }, onRun(call) { if (call.args[0] === "api" && call.args[3] === "state" && call.args[4] === "idle") { idleCalls += 1; if (idleCalls === 2) throw new Error("state channel unavailable"); } }, }); await harness.emit("session_start"); - harness.children[0].stdout.emit("data", 'one'); - await flush(); + await harness.emit("agent_start"); + await harness.emit("agent_end", { messages: [assistantMessage("local result")] }); - assert.deepEqual(commandCalls(harness, "send"), []); - assert.equal(harness.shutdowns, 1); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.ok( - harness.errors.some(({ message }) => - message.includes("could not restore idle state after a failed submission"), - ), - ); - assert.deepEqual(harness.clock.delays(), []); + assert.equal(harness.shutdowns, 0); + assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 0); + assert.deepEqual(harness.clock.delays(), [5]); + assert.match(harness.statuses.at(-1).text, /comms recovering/); + + await harness.clock.runNext(5); + assert.equal(idleCalls, 3); + assert.doesNotMatch(harness.statuses.at(-1).text, /comms recovering/); + await harness.emit("session_shutdown"); } -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] +// [unit->REQ-OMP-COMMS-RECOVERY] async function testListenerRestartExhaustion() { const harness = createHarness({ restartDelaysMs: [5, 10] }); await harness.emit("session_start"); const first = harness.children[0]; first.emit("close", 7); assert.deepEqual(harness.clock.delays(), [5]); await harness.clock.runNext(5); const second = harness.children[1]; second.stdout.emit("data", 'half'); second.emit("error", new Error("listener crashed")); second.emit("close", 8); await flush(); assert.deepEqual(harness.clock.delays(), [10], "error plus close schedules one restart"); await harness.clock.runNext(10); const third = harness.children[2]; third.emit("close", 9); await flush(); assert.equal(harness.children.length, 3); - assert.equal(harness.shutdowns, 1); - assert.ok( - harness.errors.some(({ message }) => message.includes("listener restart budget exhausted")), - ); - assert.ok( - harness.notifications.some(({ message, type }) => - type === "error" && message.includes("listener restart budget exhausted"), - ), - ); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.deepEqual(harness.clock.delays(), []); + assert.equal(harness.shutdowns, 0); + assert.deepEqual(harness.clock.delays(), [10]); + assert.match(harness.statuses.at(-1).text, /comms recovering/); + await harness.clock.runNext(10); + assert.equal(harness.children.length, 4, "listener retries indefinitely at capped backoff"); await harness.emit("session_shutdown"); assert.equal( harness.calls.filter((call) => call.args[3] === "session-end").length, 1, - "fatal shutdown and lifecycle shutdown share one teardown", ); } async function testListenerStableIntervalResetsRetries() { const harness = createHarness({ listenerStableMs: 20, restartDelaysMs: [5, 10], }); await harness.emit("session_start"); await harness.emit("agent_start"); assert.deepEqual(harness.clock.delays(), [20]); harness.children[0].emit("close", 1); assert.deepEqual(harness.clock.delays(), [5]); await harness.clock.runNext(5); const shortLived = harness.children[1]; shortLived.stdout.emit( "data", 'a parsed event is not stability', ); shortLived.emit("close", 2); assert.deepEqual( harness.clock.delays(), [10], "a parsed event followed by an immediate crash remains in the consecutive crash loop", ); await harness.clock.runNext(10); const stable = harness.children[2]; assert.deepEqual(harness.clock.delays(), [20]); await harness.clock.runNext(20); stable.emit("close", 3); assert.deepEqual( harness.clock.delays(), [5], "a listener surviving the stable interval resets the next retry to attempt one", ); assert.match( harness.notifications.filter(({ type }) => type === "warning").at(-1).message, - /restarting 1\/2 in 5ms/, + /retrying in 5ms/, ); await harness.clock.runNext(5); assert.equal(harness.children.length, 4); await harness.emit("session_shutdown"); assert.deepEqual(harness.clock.delays(), []); } -async function testSessionEndRetriesAfterTransientFailure() { - const firstEnd = deferred(); - let endAttempts = 0; - const harness = createHarness({ - restartDelaysMs: [], - sessionEndRetryDelaysMs: [5], - onRun(call) { - if (call.args[0] === "api" && call.args[3] === "session-end") { - endAttempts += 1; - if (endAttempts === 1) return firstEnd.promise; - } - }, - }); - await harness.emit("session_start"); - harness.children[0].emit("close", 12); - await flush(); - assert.equal( - harness.calls.filter((call) => call.args[3] === "session-end").length, - 1, - ); - - firstEnd.reject(new Error("transient teardown failure")); - await flush(); - assert.equal(harness.shutdowns, 0, "fatal close must wait for the bounded teardown retry"); - assert.deepEqual(harness.clock.delays(), [5]); - assert.ok( - harness.errors.some( - ({ message, details }) => - message.includes("session teardown failed; retrying") && - details.error.includes("transient teardown failure"), - ), - ); - - await harness.clock.runNext(5); - await flush(); - const shutdown = harness.emit("session_shutdown"); - await shutdown; - await flush(); - assert.equal( - harness.calls.filter((call) => call.args[3] === "session-end").length, - 2, - "normal fatal teardown may retry before bounded lifecycle shutdown begins", - ); - assert.equal(harness.shutdowns, 1); - assert.deepEqual(harness.clock.delays(), []); -} +// [unit->REQ-OMP-COMMS-RECOVERY] async function testHumanBusyFailureFailsClosed() { + let busyCalls = 0; const harness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "state" && call.args[4] === "busy") { - throw new Error("state channel unavailable"); + busyCalls += 1; + if (busyCalls === 1) throw new Error("state channel unavailable"); } }, }); await harness.emit("session_start"); await harness.emit("agent_start"); - assert.equal(harness.shutdowns, 1); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.ok( - harness.errors.some(({ message }) => message.includes("could not mark the endpoint busy")), - ); - assert.equal(harness.children[0].kills, 1); - assert.deepEqual(harness.clock.delays(), []); + assert.equal(harness.shutdowns, 0); + assert.equal(harness.children[0].kills, 0); + assert.deepEqual(harness.clock.delays(), [5]); + assert.match(harness.statuses.at(-1).text, /comms recovering/); + await harness.clock.runNext(5); + assert.equal(busyCalls, 2); + assert.doesNotMatch(harness.statuses.at(-1).text, /comms recovering/); + await harness.emit("session_shutdown"); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] +// [unit->REQ-OMP-CORE-DELIVERY] +// [unit->REQ-OMP-COMMS-RECOVERY] async function testShutdownReapsAndReleasesQueuedCustody() { let listener; let listenerWasLiveAtSessionEnd = false; const harness = createHarness({ onSpawn(child) { listener = child; }, onRun(call) { if (call.args[3] === "session-end") listenerWasLiveAtSessionEnd = listener.kills === 0; }, }); await harness.emit("session_start"); listener.stdout.emit( "data", 'onetwo', ); await flush(); await harness.emit("agent_start"); - await harness.emit("agent_end", { + const boundary = await harness.emit("context", { messages: [ { role: "user", content: '' }, - assistantMessage("done"), + { role: "user", content: '' }, ], }); + await harness.emit("agent_end", { + messages: [...boundary.messages, assistantMessage("done")], + }); assert.deepEqual(harness.submitted, ['', '']); await harness.emit("session_shutdown"); assert.equal(listener.kills, 1); assert.deepEqual(harness.clock.delays(), []); assert.deepEqual( commandCalls(harness, "send"), [], "shutdown must not synthesize outbound peer messages", ); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.equal( listenerWasLiveAtSessionEnd, true, "authenticated session-end must clear durable liveness before listener reaping spends the shutdown budget", ); assert.deepEqual(harness.statuses.at(-1), { key: "omp-spt", text: undefined }); assert.equal(harness.shutdowns, 0, "normal lifecycle shutdown must not recursively shut down OMP"); } async function testRunSptRejectsStdinErrorsAndHungCommands() { const epipeClock = new FakeClock(); const epipeChild = new FakeChild(); const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); epipeChild.stdin.onEnd = () => { epipeChild.stdin.emit("error", epipe); return false; }; await assert.rejects( runSpt(["send", "peer", "--from", "omp-agent"], "reply", { clearTimeout: epipeClock.clearTimeout.bind(epipeClock), commandTimeoutMs: 20, killForceMs: 4, killGraceMs: 3, setTimeout: epipeClock.setTimeout.bind(epipeClock), spawnProcess: () => epipeChild, }), (error) => error === epipe && error.code === "EPIPE", ); assert.deepEqual(epipeChild.killSignals, ["SIGTERM"]); assert.deepEqual(epipeClock.delays(), []); const fastExitClock = new FakeClock(); const fastExitChild = new FakeChild(); fastExitChild.stdin.onEnd = () => { fastExitChild.close(0); return false; }; await assert.rejects( runSpt(["send", "peer", "--from", "omp-agent"], "reply", { clearTimeout: fastExitClock.clearTimeout.bind(fastExitClock), commandTimeoutMs: 20, killForceMs: 4, killGraceMs: 3, setTimeout: fastExitClock.setTimeout.bind(fastExitClock), spawnProcess: () => fastExitChild, }), /exited before stdin completed/, ); assert.deepEqual(fastExitChild.killSignals, []); assert.deepEqual(fastExitClock.delays(), []); const commandCases = [ ["api", "--adapter", "omp-spt", "bind", "omp-agent"], ["send", "peer", "--from", "omp-agent"], ["api", "--adapter", "omp-spt", "state", "idle", "omp-agent"], ["api", "--adapter", "omp-spt", "session-end", "omp-agent"], ]; for (const args of commandCases) { const clock = new FakeClock(); const child = new FakeChild(); const pending = runSpt(args, args[0] === "send" ? "outcome" : undefined, { clearTimeout: clock.clearTimeout.bind(clock), commandTimeoutMs: 7, killForceMs: 4, killGraceMs: 3, setTimeout: clock.setTimeout.bind(clock), spawnProcess: () => child, }); const rejected = assert.rejects(pending, /timed out after 7ms/); assert.deepEqual(clock.delays(), [7]); await clock.runNext(7); await rejected; assert.deepEqual(child.killSignals, ["SIGTERM"]); assert.deepEqual(clock.delays(), []); } } -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] +// [unit->REQ-OMP-COMMS-RECOVERY] async function testListenerTerminationEscalatesAndReaps() { const harness = createHarness({ killForceMs: 4, killGraceMs: 3, onSpawn(child) { child.onKill = (signal) => { if (signal === "SIGKILL") child.close(null, signal); return true; }; }, }); await harness.emit("session_start"); const listener = harness.children[0]; const shutdown = harness.emit("session_shutdown"); await flush(); assert.deepEqual(listener.killSignals, ["SIGTERM"]); assert.deepEqual(harness.clock.delays().sort((a, b) => a - b), [3, 1_800]); await harness.clock.runNext(3); await shutdown; assert.deepEqual(listener.killSignals, ["SIGTERM", "SIGKILL"]); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); const errorHarness = createHarness({ killForceMs: 4, killGraceMs: 3, onSpawn(child) { child.onKill = (signal) => { if (signal === "SIGKILL") child.close(null, signal); return true; }; }, }); await errorHarness.emit("session_start"); const erroredListener = errorHarness.children[0]; erroredListener.emit("error", new Error("listener pipe failed")); const concurrentShutdown = errorHarness.emit("session_shutdown"); await flush(); assert.deepEqual(erroredListener.killSignals, ["SIGTERM"]); assert.deepEqual(errorHarness.clock.delays().sort((a, b) => a - b), [3, 1_800]); await errorHarness.clock.runNext(3); await concurrentShutdown; assert.deepEqual(erroredListener.killSignals, ["SIGTERM", "SIGKILL"]); assert.equal( errorHarness.calls.filter((call) => call.args[3] === "session-end").length, 1, ); assert.deepEqual(errorHarness.clock.delays(), []); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] +// [unit->REQ-OMP-COMMS-RECOVERY] async function testProtocolCorruptionFailsClosed() { async function failProtocol(payload, expected, options = {}) { - const harness = createHarness({ restartDelaysMs: [], ...options }); + const harness = createHarness({ restartDelaysMs: [5], ...options }); await harness.emit("session_start"); harness.children[0].stdout.emit("data", payload); await flush(); - assert.equal(harness.shutdowns, 1); + assert.equal(harness.shutdowns, 0); assert.deepEqual(harness.submitted, []); assert.deepEqual(commandCalls(harness, "send"), []); assert.ok( harness.errors.some( ({ message, details }) => message.includes("listener protocol corruption") && expected.test(details.error), ), ); assert.equal(harness.children[0].kills, 1); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.deepEqual(harness.clock.delays(), []); + assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 0); + assert.deepEqual(harness.clock.delays(), [5]); + await harness.clock.runNext(5); + assert.equal(harness.children.length, 2); await harness.emit("session_shutdown"); return harness; } await failProtocol( 'truncatedvalid'.padEnd( 128, "x", ), /buffer exceeded/, { listenerBufferLimit: 96 }, ); await failProtocol('missing sender', /missing EVENT from/); await failProtocol('bad attrs', /malformed EVENT attributes/); await failProtocol('never closes'.padEnd(80, "x"), /buffer exceeded/, { listenerBufferLimit: 64, }); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] +// [unit->REQ-OMP-CORE-DELIVERY] +// [unit->REQ-OMP-COMMS-RECOVERY] async function testInboundQueueOverflowReleasesAcceptedCustody() { const frames = ["a", "b", "overflow"].map( (from) => `work`, ); const harness = createHarness({ acceptedQueueLimit: 2, - restartDelaysMs: [], + restartDelaysMs: [5], }); await harness.emit("session_start"); harness.children[0].stdout.emit("data", frames.join("")); await flush(); - assert.equal(harness.shutdowns, 1); - assert.deepEqual(harness.submitted, []); - assert.deepEqual( - commandCalls(harness, "send"), - [], - "capacity failure must not synthesize outbound peer messages", - ); + assert.equal(harness.shutdowns, 0); + assert.deepEqual(harness.submitted, ['', '']); + assert.deepEqual(commandCalls(harness, "send"), []); assert.ok( harness.errors.some(({ message }) => - message.includes("inbound custody capacity exceeded"), + message.includes("inbound listener capacity exceeded"), ), ); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.deepEqual(harness.clock.delays(), []); + assert.equal(harness.children[0].kills, 1); + assert.deepEqual(harness.clock.delays(), [5]); + + await harness.emit("agent_start"); + const boundary = await harness.emit("context", { + messages: [ + { role: "user", content: '' }, + { role: "user", content: '' }, + ], + }); + assert.equal(boundary.messages.length, 2); + assert.deepEqual( + harness.submitted, + ['', '', ''], + "freeing observed custody admits the held overflow item without loss", + ); await harness.emit("session_shutdown"); const byteFirst = 'x'; const byteOverflow = '😀'; const byteHarness = createHarness({ acceptedBytesLimit: Buffer.byteLength(byteFirst, "utf8") + byteOverflow.length, acceptedQueueLimit: 10, - restartDelaysMs: [], + restartDelaysMs: [5], }); await byteHarness.emit("session_start"); byteHarness.children[0].stdout.emit("data", `${byteFirst}${byteOverflow}`); await flush(); - assert.deepEqual(commandCalls(byteHarness, "send"), []); - assert.equal(byteHarness.shutdowns, 1); - assert.deepEqual(byteHarness.clock.delays(), []); + assert.deepEqual(byteHarness.submitted, ['']); + assert.equal(byteHarness.shutdowns, 0); + assert.deepEqual(byteHarness.clock.delays(), [5]); await byteHarness.emit("session_shutdown"); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] +// [unit->REQ-OMP-CORE-DELIVERY] +// [unit->REQ-OMP-COMMS-RECOVERY] async function testShutdownFallbackStaysBelowHostCap() { const never = new Promise(() => {}); const harness = createHarness({ shutdownBudgetMs: 1_800, shutdownCommandTimeoutMs: 300, onRun(call) { if (call.args[3] === "session-end") return never; }, }); await harness.emit("session_start"); const shutdown = harness.emit("session_shutdown"); await flush(); assert.deepEqual( harness.clock.delays().sort((a, b) => a - b), [300, 1_800], "session-end has a short command timeout inside the 2s host cap", ); await harness.clock.runNext(300); await shutdown; assert.equal(harness.children[0].kills, 1); assert.deepEqual(commandCalls(harness, "send"), []); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); assert.ok(1_800 < 2_000); } // [unit->REQ-PARITY-READY-ACTIVATION] // [unit->REQ-PARITY-LIVE-ACTIVATION] async function testNativeActivationCommandsAndErrors() { const inert = createHarness({ id: null }); assert.deepEqual([...inert.commands.keys()], ["ready", "live"]); assert.ok(inert.tools.has("spt_checkpoint")); await inert.emit("session_start"); assert.deepEqual(inert.calls, [], "an ordinary OMP session must remain lifecycle-inert"); assert.equal( await inert.emit("session_before_switch", { reason: "new" }), undefined, "an unbound extension must not block native session changes", ); await inert.commands.get("ready").handler("--auto", inert.ctx); await inert.commands.get("live").handler("two identities", inert.ctx); assert.deepEqual(inert.calls, [], "invalid activation syntax must not bind or guess"); assert.ok( inert.notifications.some(({ message }) => message.includes("supported only by `/live`")), ); assert.ok(inert.notifications.some(({ message }) => message.includes("Usage: /live"))); await inert.commands.get("ready").handler("ready-one", inert.ctx); const readyBind = inert.calls.find((call) => call.args[3] === "bind"); assert.deepEqual(readyBind.args, [ "api", "--adapter", "omp-spt", "bind", "ready-one", "--set-session-id", "session-1", "--type", "ready_agent", ]); assert.deepEqual(inert.children[0].args, [ "api", "--adapter", "omp-spt", "listen", "ready-one", "--session-id", "session-1", ]); assert.ok( inert.notifications.some(({ message, type }) => type === "info" && message.includes("ready endpoint ready-one"), ), ); await inert.commands.get("live").handler("other-id", inert.ctx); assert.equal(inert.calls.filter((call) => call.args[3] === "bind").length, 1); assert.ok( @@ -1546,213 +1493,217 @@ async function testStartupBriefHintsAndUpdateNotices() { if (call.args[0] === "--json" && call.args[1] === "notif") { throw new Error("notification store unavailable"); } if (call.args[0] === "adapter" && call.args[1] === "version") { throw new Error("adapter version unavailable"); } }, }); await unavailable.emit("session_start"); await flush(); const noClaim = await unavailable.emit("before_agent_start", { prompt: "Continue the task.", systemPrompt: [], }); assert.ok(noClaim.systemPrompt.at(-1).includes("OMP SPT endpoint")); assert.ok(!noClaim.systemPrompt.at(-1).includes("OMP SPT updates:")); await unavailable.emit("session_shutdown"); let delayedClock; const delayed = createHarness({ checkUpdates: true, fetchLatestAdapterVersion() { return new Promise((resolve) => { delayedClock.setTimeout(() => resolve("v0.3.0"), 5_000); }); }, onRun(call) { let value; if (call.args[0] === "--version") value = "spt 1.0.0"; else if (call.args[0] === "--json" && call.args[1] === "notif") { value = JSON.stringify({ notifs: [ { from_id: "spt-update", kind: "consent", state: "seen:1", head: "An spt-core update v1.2.0 is available.", }, ], }); } else if (call.args[0] === "adapter" && call.args[1] === "version") { value = "0.2.0"; } else { return; } return new Promise((resolve) => { delayedClock.setTimeout(() => resolve(value), 5_000); }); }, }); delayedClock = delayed.clock; await delayed.emit("session_start"); let firstContextSettled = false; const delayedFirstContext = delayed .emit("before_agent_start", { prompt: "Continue.", systemPrompt: [] }) .then((result) => { firstContextSettled = true; return result; }); await flush(); assert.equal( firstContextSettled, true, "the first-turn context must not await slow local update commands", ); const firstContext = await delayedFirstContext; assert.ok(firstContext.systemPrompt.at(-1).includes("OMP SPT endpoint")); assert.ok(!firstContext.systemPrompt.at(-1).includes("OMP SPT updates:")); assert.deepEqual(delayed.clock.delays(), [5_000, 5_000, 5_000, 5_000]); for (let index = 0; index < 4; index += 1) await delayed.clock.runNext(5_000); assert.equal(delayed.sentMessages.length, 1); assert.deepEqual(delayed.sentMessages[0].delivery, { deliverAs: "nextTurn", triggerTurn: false, }); assert.match(delayed.sentMessages[0].message.content, /spt-core v1\.2\.0 is available/); assert.match(delayed.sentMessages[0].message.content, /omp-spt v0\.3\.0 is available/); await delayed.emit("session_shutdown"); } +// [unit->REQ-OMP-CORE-DELIVERY] // [unit->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] // [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function testActiveTurnBoundaryDeliveryAndFallback() { - const harness = createHarness(); + let pollCalls = 0; + const polledEnvelope = 'busy'; + const harness = createHarness({ + onRun(call) { + if (call.args[0] === "api" && call.args[3] === "poll") { + pollCalls += 1; + return pollCalls === 1 ? polledEnvelope : ""; + } + }, + }); await harness.emit("session_start"); + await harness.emit("before_agent_start", { prompt: "operator prompt", systemPrompt: [] }); await harness.emit("agent_start"); const firstEnvelope = 'one'; const secondEnvelope = 'two'; harness.children[0].stdout.emit("data", `${firstEnvelope}${secondEnvelope}`); await flush(); - assert.deepEqual(harness.submitted, ['']); + assert.deepEqual( + harness.submitted, + ['', ''], + "live-listener arrivals use the native steer path independently", + ); const boundary = await harness.emit("context", { messages: [ - { role: "user", content: "operator prompt" }, { role: "user", content: '' }, + { role: "user", content: '' }, ], }); assert.equal( - boundary.messages.at(-1).content, + boundary.messages[0].content, `\n\n${formatInboundEnvelope(firstEnvelope)}`, - "an active-turn arrival must steer the running model at its next boundary", ); - const afterToolBoundary = await harness.emit("context", { - messages: [ - { role: "user", content: "operator prompt" }, - { role: "user", content: '' }, - assistantMessage("", { stopReason: "toolUse", toolCalls: [{ name: "read" }] }), - { role: "toolResult", content: "tool output" }, - ], - }); assert.equal( - afterToolBoundary.messages[1].content, - `\n\n${formatInboundEnvelope(firstEnvelope)}`, - "the steered delivery envelope must persist across later model continuations", - ); + boundary.messages[1].content, + `\n\n${formatInboundEnvelope(secondEnvelope)}`, + ); + assert.deepEqual(boundary.messages.at(-1), { + role: "custom", + customType: "spt-event", + content: polledEnvelope, + display: false, + attribution: "user", + timestamp: boundary.messages.at(-1).timestamp, + }); + assert.equal(pollCalls, 1, "busy context boundaries drain active-only core custody"); + assert.deepEqual(harness.sentMessages, [], "busy poll output adds no user-visible panel"); + await harness.emit("agent_end", { - messages: [ - { role: "user", content: "operator prompt" }, - assistantMessage("", { stopReason: "toolUse", toolCalls: [{ name: "read" }] }), - { role: "toolResult", content: "tool output" }, - assistantMessage("first outcome"), - ], + messages: [...boundary.messages, assistantMessage("local outcome")], }); - assert.deepEqual( - commandCalls(harness, "send"), - [], - "assistant output at an active boundary must remain local", - ); + assert.deepEqual(commandCalls(harness, "send"), []); assert.deepEqual(harness.clock.delays(), []); - assert.deepEqual(harness.submitted, ['', '']); - assert.deepEqual(harness.submittedDeliveries, [undefined, undefined]); - assertNoAgentManagedPoll(harness); await harness.emit("session_shutdown"); } // [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function testAbnormalTurnsRestoreReceivability() { for (const [label, completionEvent] of [ ["cancelled", "session_stop"], ["interrupted", "session_stop"], ["failed", "agent_end"], ]) { const harness = createHarness(); await harness.emit("session_start"); await harness.emit("agent_start"); harness.children[0].stdout.emit( "data", `work`, ); await flush(); const boundary = await harness.emit("context", { messages: [ { role: "user", content: "active work" }, { role: "user", content: `` }, ], }); await harness.emit(completionEvent, { messages: boundary.messages }); assert.deepEqual(commandCalls(harness, "send"), []); assert.equal(stateCalls(harness).at(-1).args[4], "idle"); harness.children[0].stdout.emit( "data", `next`, ); await flush(); assert.deepEqual(harness.submitted, [ ``, ``, ]); await harness.emit("session_shutdown"); } } async function testCompletionStopReasonGatesSideEffects() { for (const stopReason of ["aborted", "error"]) { const harness = createHarness(); await harness.emit("session_start"); harness.children[0].stdout.emit( "data", `work`, ); await flush(); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ { role: "user", content: `` }, assistantMessage("@partial output", { stopReason, errorMessage: stopReason === "error" ? "provider unavailable" : undefined, }), ], }); const sends = commandCalls(harness, "send"); assert.deepEqual( sends, [], `${stopReason} partial output must not produce peer-message side effects`, ); assert.deepEqual( harness.sentMessages.filter( ({ message }) => message.customType === "omp-spt-peer-status", ), [], ); assert.equal(stateCalls(harness).at(-1).args[4], "idle"); await harness.emit("session_shutdown"); } } async function testCompactionPreservesLocalAssistantOutput() { const highHistory = Array.from({ length: 64 }, (_unused, index) => @@ -1954,104 +1905,104 @@ async function testNativeCheckpointTool() { }, }); await harness.emit("session_start"); const tool = harness.tools.get("spt_checkpoint"); const checkpoint = tool.execute( "tool-1", { wake: "Continue release preparation." }, undefined, undefined, harness.ctx, ); await flush(); assert.deepEqual(harness.sentMessages, [], "checkpoint wake must wait for native compaction"); compactGate.resolve(); const result = await checkpoint; assert.equal(result.details.ok, true); assert.equal(harness.compactions.length, 1); assert.deepEqual(harness.sentMessages[0], { message: { customType: "omp-spt-checkpoint-wake", content: "Continue release preparation.", display: true, attribution: "user", }, delivery: { deliverAs: "nextTurn", triggerTurn: true }, }); await harness.emit("session_shutdown"); const ready = createHarness({ id: null }); await ready.emit("session_start"); await ready.commands.get("ready").handler("plain-ready", ready.ctx); const refused = await ready.tools .get("spt_checkpoint") .execute("tool-2", {}, undefined, undefined, ready.ctx); assert.equal(refused.isError, true); assert.equal(refused.details.reason, "not-live"); assert.deepEqual(ready.compactions, []); await ready.emit("session_shutdown"); const launchedReady = createHarness({ onRun(call) { if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { return JSON.stringify({ endpoint_type: "ready_agent" }); } }, }); await launchedReady.emit("session_start"); const launchedRefusal = await launchedReady.tools .get("spt_checkpoint") .execute("tool-launch-ready", {}, undefined, undefined, launchedReady.ctx); assert.equal(launchedRefusal.details.reason, "not-live"); assert.deepEqual(launchedReady.compactions, []); await launchedReady.emit("session_shutdown"); const failed = createHarness({ onCompact() { throw new Error("native compaction cancelled"); }, onRun(call) { if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { return JSON.stringify({ endpoint_type: "live_agent" }); } }, }); await failed.emit("session_start"); const failure = await failed.tools .get("spt_checkpoint") .execute("tool-3", {}, undefined, undefined, failed.ctx); assert.equal(failure.isError, true); assert.match(failure.content[0].text, /native compaction cancelled/); assert.deepEqual(failed.sentMessages, [], "a failed reset must never queue a false wake"); await failed.emit("session_shutdown"); } await testParsing(); await testEndpointSessionNameAndAnimatedWindowTitle(); await testRunSptRejectsStdinErrorsAndHungCommands(); await testLifecycleCustodyAndContext(); await testLocalAssistantOutputDoesNotReplyToPeer(); await testDeferredBindLifecycleSerialization(); +await testStateReconciliationUsesLatestActivity(); await testSubmissionFailureAdvancesQueue(); await testFailedIdleRecoveryFailsClosed(); await testListenerRestartExhaustion(); await testListenerStableIntervalResetsRetries(); -await testSessionEndRetriesAfterTransientFailure(); await testHumanBusyFailureFailsClosed(); await testShutdownReapsAndReleasesQueuedCustody(); await testListenerTerminationEscalatesAndReaps(); await testProtocolCorruptionFailsClosed(); await testInboundQueueOverflowReleasesAcceptedCustody(); await testShutdownFallbackStaysBelowHostCap(); await testNativeActivationCommandsAndErrors(); await testNativeActivationSelectionAndCompletion(); await testPlatformAwareActivationCandidatePaths(); await testExplicitLiveAutoResume(); await testStartupBriefHintsAndUpdateNotices(); await testActiveTurnBoundaryDeliveryAndFallback(); await testAbnormalTurnsRestoreReceivability(); await testCompletionStopReasonGatesSideEffects(); await testCompactionPreservesLocalAssistantOutput(); await testPeerShortformParsingAndDispatch(); await testNativeCheckpointTool(); console.log("OMP-EXTENSION OK"); diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 41f26d8..37aa14b 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -1,164 +1,164 @@ # OMP-native requirement registry. Evidence roots are intentionally limited to # the retained adapter surface; retired foreign-harness files are not scanned. [scan] roots = [ "OMP-ADAPTER-PLAN.md", "docs/KNOWN-HAZARDS.md", "docs/adr/0007-native-omp-tui-hosts-spt-extension.md", "docs/adr/0008-omp-native-product-boundary.md", "docs/adr/0009-all-endpoints-use-native-omp.md", - "docs/adr/0010-native-delivery-self-heals-or-closes.md", + "docs/adr/0010-native-delivery-recovers-without-stopping-local-work.md", "docs/adr/0011-endpoint-session-binding-is-immutable.md", "docs/adr/0012-continuity-drops-live-under-spt.md", "docs/adr/0013-release-gate-stops-at-the-adapter-boundary.md", "docs/PARITY.md", "docs/adr/0015-extension-owned-session-activation.md", "docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md", "docs/adr/0016-agent-capabilities-split-by-native-seam.md", "docs/adr/0018-checkpoint-resets-context-natively.md", "adapter/omp-spt.toml", "adapter/strings/omp-spt.mjs", "adapter/strings/skills", "tools/omp-spt/Cargo.toml", "tools/omp-spt/src/main.rs", "tools/omp-spt/src/digest_omp.rs", "tools/omp-spt/src/history_omp.rs", "tools/omp-spt/src/echo_commune_omp.rs", "tools/omp-spt/src/psyche_omp.rs", "tools/omp-spt/src/launch_omp.rs", "tools/omp-spt/tests/launch_omp.rs", "tools/omp-spt/tests/captured_session_env.rs", "tests/manifest-shortcut.sh", "tests/native-launch-manifest.sh", "tests/omp-extension.mjs", "tests/omp-skills.mjs", "tests/manifest-schema.sh", "tests/adapter-archive.sh", "ci/manifest/check-manifest.sh", "ci/manifest/validate_manifest.py", "ci/publish/package-adapter.sh", "ci/publish/release-acquire-int.sh", "ci/digest/build.sh", "ci/digest/digest-proof-int.sh", "docs/RELEASE-RUNBOOK.md", "docs-site/README.md", "docs-site/src/reference/release-evidence.md", ".github/workflows/docs-pages.yml", "ci/docs/build-docs.py", "ci/release/check-version-consistency.py", "ci/release/validate-release-evidence.py", "ci/run-gates.sh", "tests/docs-gate.py", "tests/release-evidence.py", "tests/version-consistency.py", ] [policy] required_stages = [] [[requirements]] id = "REQ-OMP-NATIVE-TUI" title = "The hosted launch contract hands validated OMP its packaged extension as the broker PTY process" required_stages = ["doc", "impl", "unit", "int"] # Intentionally inactive until an actual release runs the ADR-0013 acceptance gate. Deterministic # fake-process launch evidence must never satisfy this live endpoint requirement. [[requirements]] id = "REQ-OMP-LIVE-RELEASE-GATE" title = "Pending live gate: a real OMP release must satisfy the ADR-0013 endpoint acceptance criteria" required_stages = [] [[requirements]] id = "REQ-OMP-EXECUTABLE-RESOLUTION" title = "Fresh, resumed, and daemon-driven OMP turns resolve and validate Oh My Pi, reject executable collisions, and fail loudly" required_stages = ["doc", "impl", "unit", "int"] [[requirements]] -id = "REQ-OMP-EXTENSION-CUSTODY" -title = "The OMP extension serializes accepted deliveries while outbound peer messages require an explicit CLI or shortform action" +id = "REQ-OMP-CORE-DELIVERY" +title = "spt-core retains durable message custody while listener and poll deliveries independently enter OMP context and outbound peer messaging remains explicit" required_stages = ["doc", "impl", "unit"] [[requirements]] -id = "REQ-OMP-LISTENER-FAIL-CLOSED" -title = "Unexpected listener exit retries within a finite budget and then performs SPT session-end plus OMP shutdown" +id = "REQ-OMP-COMMS-RECOVERY" +title = "An established endpoint keeps local OMP work alive while listener, activity, and poll communications recover visibly" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-SESSION-IMMUTABLE" title = "One endpoint owns one OMP session for its lifetime and blocks every in-TUI session-changing action" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-SESSION-TITLES" title = "Hosted OMP sessions use endpoint, node, and project names while the terminal title visibly tracks idle and animated busy state" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-MESSAGE-CONTEXT" title = "Each peer delivery opens one ordinary OMP turn containing a message stub and the complete SPT event context" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-READY-LIVE" title = "The adapter advertises exactly ReadyAgent and LiveAgent endpoint types on the same native OMP hosting path" required_stages = ["doc", "unit"] [[requirements]] id = "REQ-OMP-CONTINUITY-DROPS" title = "Commune and signoff drops resolve project-locally under .spt on the published v0.29.0 endpoint-cwd contract" required_stages = ["doc", "unit"] [[requirements]] id = "REQ-PSYCHE-EPHEMERAL-SHIM" title = "Each LiveAgent Psyche event runs as one bounded OMP turn with file-backed context and explicit reseed signaling" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-HISTORY-FETCHER" title = "The history fetcher locates exactly one OMP session and streams its JSONL bytes verbatim" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-DIST-DIGEST-EXTRACTOR" title = "The OMP digest fetcher maps native session JSONL into the published harness-neutral digest record stream" required_stages = ["doc", "impl", "unit", "int"] [[requirements]] id = "REQ-SESSION-ECHO-COMMUNE" title = "The bounded echo-commune role summarizes one OMP session and reports real OMP failures without latching on a locate miss" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-DIST-MANIFEST-SCHEMA" title = "The omp-spt manifest validates offline against spt-core's published manifest schema" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-DIST-ADAPTER-RELEASE" title = "One multi-platform adapter.spt ships the manifest, native OMP extension, and omp-spt binary through BigscreenVR/omp-spt releases" required_stages = ["doc", "impl", "unit", "int"] [[requirements]] id = "REQ-DIST-BINARY-CONSOLIDATE" title = "One omp-spt helper binary dispatches only the retained OMP-native launch, digest, history, Psyche, and echo-commune roles" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-PARITY-READY-ACTIVATION" title = "An extension-native command binds an ordinary OMP session as a ready endpoint while the extension owns its listener" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-PARITY-LIVE-ACTIVATION" title = "An extension-native command binds an ordinary OMP session as a live endpoint with durable Psyche continuity" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-PARITY-LIVE-AUTO-RESUME" title = "Explicit live auto-resume selects the most-recent compatible live identity without silently guessing on ordinary activation" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-PARITY-SAFE-BOUNDARY-DELIVERY" title = "Accepted peer messages enter an active OMP turn before its next tool or model continuation, with ordered next-turn fallback" [raw output: artifact://77]