diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 527de4d..e4068b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: outputs: continuity: ${{ steps.classify.outputs.continuity }} cluster: ${{ steps.classify.outputs.cluster }} + official_omp_gate0: ${{ steps.classify.outputs.official_omp_gate0 }} tooling: ${{ steps.classify.outputs.tooling }} android_debug: ${{ steps.classify.outputs.android_debug }} flutter: ${{ steps.classify.outputs.flutter }} @@ -174,6 +175,53 @@ jobs: if-no-files-found: error retention-days: 14 + official-omp-gate0: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.official_omp_gate0 == 'true' }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x64 + runner: ubuntu-24.04 + - platform: linux-arm64 + runner: ubuntu-24.04-arm + name: official-omp-gate0 (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 10 + steps: + - name: Check out exact T4 source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Prove the pinned unmodified official OMP runtime + run: pnpm --filter @t4-code/host-service verify:official-omp-lifecycle + + - name: Upload official OMP Gate 0 evidence + if: ${{ success() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: official-omp-gate0-${{ matrix.platform }}-${{ github.run_id }} + path: artifacts/official-omp-gate0/${{ matrix.platform }}.json + if-no-files-found: error + retention-days: 14 + cluster: needs: changes if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.cluster == 'true' }} @@ -460,7 +508,7 @@ jobs: verify: name: verify if: ${{ always() }} - needs: [changes, core, legacy-bridge-continuity, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple] + needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -470,6 +518,7 @@ jobs: CHANGES_RESULT: ${{ needs.changes.result }} CORE_RESULT: ${{ needs.core.result }} CONTINUITY_RESULT: ${{ needs.legacy-bridge-continuity.result }} + OFFICIAL_OMP_GATE0_RESULT: ${{ needs.official-omp-gate0.result }} CLUSTER_RESULT: ${{ needs.cluster.result }} TOOLING_RESULT: ${{ needs.tooling.result }} ANDROID_RESULT: ${{ needs.android-debug.result }} @@ -482,6 +531,7 @@ jobs: test "$CORE_RESULT" = success for result in \ "$CONTINUITY_RESULT" \ + "$OFFICIAL_OMP_GATE0_RESULT" \ "$CLUSTER_RESULT" \ "$TOOLING_RESULT" \ "$ANDROID_RESULT" \ diff --git a/.gitignore b/.gitignore index 222005a..6165d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist-electron/ # Test and runtime output /artifacts/legacy-bridge-continuity/ +/artifacts/official-omp-gate0/ /.continuity/ /evidence/wire/ coverage/ diff --git a/README.md b/README.md index 9a0c050..50ed14d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ T4 Code v0.1.30 packages its own standalone `t4-host` and needs the matching OMP T4 Code v0.1.30 was verified with OMP 17.0.5 built from [`8476f445`](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4), tagged [`t4code-17.0.5-appserver-10`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. -T4 owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. T4 launches `t4-host`; that daemon talks to OMP through the strict `t4-omp-authority/1` stdio bridge, while OMP still owns session files, locks, agent execution, and takeover decisions. Ordinary upstream OMP is not yet compatible because it does not ship that bridge. +T4 owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. The released package still launches `t4-host` against the strict `t4-omp-authority/1` bridge, while OMP owns session files, locks, agent execution, and takeover decisions. A separately pinned unmodified official OMP 17.0.6 now passes T4's direct RPC behavior gate on macOS ARM64; native Linux evidence and packaged cutover proof remain before that path replaces the released fallback. | Platform | Arch | Package | | -------- | --------------------- | ----------------------------------------- | diff --git a/compat/official-omp-gate0.json b/compat/official-omp-gate0.json new file mode 100644 index 0000000..e892515 --- /dev/null +++ b/compat/official-omp-gate0.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "gate": "official-omp-gate0", + "runtime": { + "version": "17.0.6", + "tag": "v17.0.6", + "commit": "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6" + }, + "requiredPlatforms": ["darwin-arm64", "linux-x64", "linux-arm64"], + "requiredScenarios": [ + "lifecycle", + "crash-resume", + "steer", + "follow-up", + "approval", + "cancellation", + "crash-after-dispatch-no-replay" + ], + "officialRpcSupport": { + "prompt": true, + "steer": true, + "followUp": true, + "approvalRoundTrip": true, + "abort": true, + "sessionResume": true + }, + "missingOfficialSeams": { + "readyTranscriptWatermark": true, + "liveSessionEntries": true, + "durableCommandKey": true + }, + "t4Policy": { + "transcriptAuthority": "official-omp-jsonl", + "ambiguousDispatch": "outcome-unknown-no-auto-replay", + "releasedFallback": "lycaon-authority-bridge-until-packaged-cutover" + }, + "evidence": { + "runtimeHarness": "packages/host-service/bin/official-omp-gate0.ts", + "platformOutput": "artifacts/official-omp-gate0/-.json", + "dispatchCrashTest": "packages/host-service/test/hardening.test.ts", + "clientNoReplayTest": "packages/client/test/client.test.ts" + } +} diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index 4fc57d9..d2b5650 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -1,5 +1,40 @@ { "appProtocol": "omp-app/1", + "officialRuntime": { + "package": "omp", + "version": "17.0.6", + "sourceRepository": "https://github.com/can1357/oh-my-pi", + "sourceCommit": "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6", + "sourceUrl": "https://github.com/can1357/oh-my-pi/commit/89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6", + "sourceTag": "v17.0.6", + "artifacts": { + "darwin-arm64": { + "name": "omp-darwin-arm64", + "size": 119425152, + "sha256": "d747470bcff0412e5bde7860f5dead7de15aad6b973a4b46c34f8527d9d6a4b9" + }, + "darwin-x64": { + "name": "omp-darwin-x64", + "size": 124901872, + "sha256": "9e5b4560b61fc4373f604cba9e2cc3bdab04549e830f3d5ecd80b1f7facc86bc" + }, + "linux-arm64": { + "name": "omp-linux-arm64", + "size": 149072016, + "sha256": "4d2f3e9948f81f4c3813b5c89e5eda4bdcd42ef22285d40cf48402ebfa15436d" + }, + "linux-x64": { + "name": "omp-linux-x64", + "size": 173901952, + "sha256": "27fec143aa646cae5eae9b3a0677c54d43465fc49a79b161bac739dbba244c8c" + }, + "win32-x64": { + "name": "omp-windows-x64.exe", + "size": 152957440, + "sha256": "b5c7a5b008ae2342664161ed5615337501ae833a7ce825dbbf0918281dafb2d6" + } + } + }, "appWire": { "package": "@oh-my-pi/app-wire", "version": "0.7.0", diff --git a/docs/OMP_T4_CAPABILITY_AUDIT.md b/docs/OMP_T4_CAPABILITY_AUDIT.md index 14d1ce4..6ee1c84 100644 --- a/docs/OMP_T4_CAPABILITY_AUDIT.md +++ b/docs/OMP_T4_CAPABILITY_AUDIT.md @@ -14,7 +14,7 @@ The largest problem is no longer a missing transport layer. T4 now has a shared 1. **Operation truth is implemented in the host but was not yet reaching the UI.** PR #111 added `typed`, `headless`, `terminal-only`, and `unavailable`; PR #113 classifies stock OMP commands and rejects known terminal-only text before it reaches the model. This sprint carries that result into desktop/web and Flutter. 2. **Important daily workflows still lack a typed app action:** plan and goal modes, session branch/fork/tree, handoff, provider login/logout, queue control, and child-agent steering. -3. **The official-OMP path is real but Gate 0 is not complete.** Restart continuity is tested on macOS, while Linux execution, steering/follow-up, approval, cancellation, and ambiguous dispatch-crash behavior still need proof. +3. **The official-OMP Gate 0 proof is complete.** Unmodified OMP 17.0.6 passed restart continuity, steering, follow-up, approval, cancellation, and the no-replay failure policy on macOS ARM64 plus native Linux x64 and ARM64. 4. **The Lycaon fork is transitional, not the desired product center.** The current public package still pins its thin authority bridge, but new compatibility work should prefer the T4-owned official-OMP adapter and add only small, extractable bridge methods when stock OMP has no usable seam. 5. **Protocol vocabulary is not UI coverage.** Every tracker row needs an explicit client disposition: direct control, palette action, read-only view, disabled explanation, terminal handoff, or unavailable. 6. **Source is ahead of the public release.** Flutter and the latest adapter work are on `main`; the latest public GitHub release remains v0.1.28. @@ -25,9 +25,11 @@ The truthful-command foundation is split cleanly between merged host work and th - **Merged:** PR #111 defines one operation contract for every runtime and client. - **Merged:** PR #113 queries official OMP's bounded `get_available_commands`, supplements omitted terminal-only commands from a pinned reviewed manifest, and blocks known terminal-only slash text before prompt dispatch. -- **Merged:** PR #114 proves restart continuity through the official adapter on macOS. +- **Corrected boundary:** PR #114 proved restart continuity with the released Lycaon fallback artifact, despite the harness's old official-OMP name. This sprint adds a separate official OMP 17.0.6 pin and runs the proof against that unmodified binary. - **Merged:** PR #117 preserves `catalog.get.result.operations` through the desktop runtime. - **Merged:** PRs #118 and #120 make web/Electron and Flutter build truthful slash menus from the runtime capability contract and fail closed when that contract is absent or unavailable. +- **This sprint:** the official OMP 17.0.6 harness passes lifecycle, crash/resume, steer, follow-up, approval, and cancellation on macOS ARM64; the host/client failure tests prove a dispatched command becomes `outcome_unknown` and is never automatically replayed. +- **This sprint:** `compat/official-omp-gate0.json` records the exact runtime, required platforms/scenarios, direct RPC support, and three missing stock seams. Linux x64 and ARM64 CI jobs pass and retain per-platform evidence. - **This sprint:** Flutter adds project Quick Open plus visible pause, resume, and manual compaction controls over existing typed commands. No new fork behavior is required. The tracker distinguishes merged source, work in this sprint, and public release state. None of the new adapter/client work is claimed as packaged desktop, Android, or iOS proof yet. @@ -38,7 +40,7 @@ The tracker distinguishes merged source, work in this sprint, and public release |---|---|---|---|---|---| | Operation capability contract | Merged | Decodes shared contract | Same web client | Dart decoder merged | PR #111 green | | Official OMP command discovery and rejection | Merged | Receives through host | Receives through host | Receives through host | PR #113 green | -| Restart continuity | macOS proof merged | Shared host behavior | Shared host behavior | Shared host behavior | PR #114 green; other Gate 0 rows open | +| Official OMP Gate 0 behavior | macOS ARM64 and native Linux x64/ARM64 pass | Shared host behavior | Shared host behavior | Shared host behavior | Stock 17.0.6 passes lifecycle, steer/follow-up, approval, cancellation, and crash/no-replay behavior | | Preserve `catalog.get.operations` | Merged host response | Merged in PR #117 | Merged in PR #117 | Already decoded | Response and live-frame client tests pass | | Capability-aware slash menu | Host rejects unsafe fallback | Implemented on `main` | Implemented on `main` | Implemented on `main` | PRs #118 and #120 are green and merged | | Project Quick Open | `files.search` merged | Implemented on `main` | Implemented on `main` | Implemented this sprint | Flutter analysis and the full 168-test suite pass locally; platform CI pending | @@ -54,7 +56,7 @@ This matrix is intentionally stricter than “the protocol supports it.” A row | Original OMP | [`can1357/oh-my-pi@89d6a8f6`](https://github.com/can1357/oh-my-pi/commit/89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6), version 17.0.6 | Current original product surface | | Lycaon OMP fork | [`lyc-aon/oh-my-pi@8476f445`](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4), tag [`t4code-17.0.5-appserver-10`](https://github.com/lyc-aon/oh-my-pi/releases/tag/t4code-17.0.5-appserver-10) | Current released thin authority bridge; transitional compatibility input | | Shared upstream base | [`can1357/oh-my-pi@9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9), version 17.0.5 | Last shared OMP point | -| T4 `main` | [`298165bc`](https://github.com/LycaonLLC/t4-code/commit/298165bce4e6f57c19f9814798d50c4aa28b4bd8), version 0.1.30 in source | Official-OMP classification, restart proof, capability-aware clients, and contract hardening are merged | +| T4 `main` | [`210ddfcb`](https://github.com/LycaonLLC/t4-code/commit/210ddfcb11b84da89b2c4b079a9517901168fa37), version 0.1.30 in source | Official-OMP classification, capability-aware clients, Flutter controls, and cluster foundations are merged | | Flutter merge | [`LycaonLLC/t4-code#104`](https://github.com/LycaonLLC/t4-code/pull/104) | New shared desktop/mobile client now on `main` | | Public T4 release | [`v0.1.28`](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.28) | Latest public release visible during the audit | @@ -65,7 +67,7 @@ Relevant planning and implementation changes: - [#109: canonical local and managed architecture](https://github.com/LycaonLLC/t4-code/pull/109) — merged; makes the shared T4-owned official-OMP adapter the intended path. - [#111: operation capability contract](https://github.com/LycaonLLC/t4-code/pull/111) — merged. - [#113: official OMP operation classification](https://github.com/LycaonLLC/t4-code/pull/113) — merged. -- [#114: official OMP restart continuity](https://github.com/LycaonLLC/t4-code/pull/114) — merged. +- [#114: lifecycle continuity harness](https://github.com/LycaonLLC/t4-code/pull/114) — merged; its artifact came from the Lycaon fallback, so this sprint supersedes that proof boundary with a separately pinned official release. - [#117: preserve OMP operation capabilities in the desktop runtime](https://github.com/LycaonLLC/t4-code/pull/117) — merged. - [#98: standard OMP view-only compatibility](https://github.com/LycaonLLC/t4-code/pull/98) — still open, but its older read-only approach must not become a second long-term adapter beside the architecture in #109. @@ -150,7 +152,7 @@ Fork PR [#22](https://github.com/lyc-aon/oh-my-pi/pull/22) removed more than 37, | Priority | Gap | Why it matters | Best patch path | |---|---|---|---| -| T0 | Official adapter Gate 0 is incomplete | A clean command catalog is not enough to prove all lifecycle and failure behavior | Finish Linux, steer/follow-up, approval, cancellation, and dispatch-crash scenarios | +| T0 | Packaged official-OMP cutover is not proven | Gate 0 now passes on macOS ARM64 and native Linux x64/ARM64, but released builds still use the Lycaon fallback | Reconcile stock JSONL through the shared adapter, then run packaged local and managed cutover proofs | | T0 | Release state is ambiguous | Source says v0.1.30 while public GitHub release remains v0.1.28 | Separate `on main`, `verified package`, and `publicly released` in the tracker/release gate | | T0 | Plan, goal, branch/fork/tree, handoff, and provider auth lack complete typed app flows | These are central OMP workflows, not decorative terminal features | Typed T4 commands backed by existing OMP RPC where possible | | T1 | Queue and pause/resume controls are not consistently exposed outside Flutter | Cross-device control needs explicit, predictable behavior | Add equally visible controls to web/Capacitor over the same typed commands | @@ -250,7 +252,7 @@ Do not send a giant T4 product PR to original OMP. A suitable upstream contribut ## Capability manifest and drift control -Persist a generated compatibility snapshot from the T4 official-OMP Gate 0 run. It should contain: +The checked snapshot at `compat/official-omp-gate0.json` now records the Gate 0 runtime, platform/scenario contract, direct RPC support, missing stock seams, and no-replay policy. Each native run writes `artifacts/official-omp-gate0/-.json` as evidence. The broader capability snapshot should next add: - Official OMP version and exact commit; when the fallback bridge is exercised, its fork commit and tag too. - Operations and aliases using `typed`, `headless`, `terminal-only`, or `unavailable`. @@ -277,9 +279,9 @@ official OMP version + commit ### Phase 0: finish the official-OMP foundation -1. Finish the open Gate 0 scenarios: Linux, steer/follow-up, approval, cancellation, and ambiguous dispatch-crash recovery. -2. Generate a compatibility snapshot from the official adapter smoke. -3. Track source, verified package, and public release status separately. +1. Reconcile stock JSONL without depending on fork-only ready watermarks or live `session_entry` frames. +2. Run packaged local and managed cutover proofs while preserving the released fallback. +3. Track source, verified package, and public release status separately, then retire fallback use only after those proofs pass. ### Phase 1: close the daily-workflow gaps diff --git a/docs/OMP_T4_CAPABILITY_TRACKER.csv b/docs/OMP_T4_CAPABILITY_TRACKER.csv index bd4968f..365749b 100644 --- a/docs/OMP_T4_CAPABILITY_TRACKER.csv +++ b/docs/OMP_T4_CAPABILITY_TRACKER.csv @@ -4,10 +4,10 @@ "H03","T0","Host","Remote pairing and multiple hosts","Not an OMP product concern","T4 host pairing authority","Code: full","Code: full","Code: full","Needs packaged cross-platform proof","Client UI over existing T4 protocol","T4-owned feature; not an upstream parity item" "H04","T0","Host","Reconnect replay and transcript reconciliation","RPC stream and session persistence","T4 host adds replay and durable projection","Code: full","Code: full","Code: full","Needs live interruption tests per platform","Verify existing protocol and clients","Protocol vocabulary is present" "H05","T1","Host","Offline cached transcript","Persistent local sessions","T4 clients cache projected state","Code: partial","Code: partial","Code: partial","Offline read behavior needs a single documented contract","Client cache policy only","OMP remains the authority after reconnect" -"H06","T0","Host","Operate pinned unmodified official OMP through the shared adapter","Official OMP exposes RPC command/event seams","Fork bridge remains the released fallback","Gate 0: partial","Gate 0: partial","Gate 0: partial","Adapter runs and restart continuity is proven on macOS; Linux, steer/follow-up, approval, cancellation, and dispatch-crash cases remain","Finish Gate 0 in T4; keep PR #98 from becoming a second long-term adapter","PRs #109, #113, and #114 are merged; packaged cutover is not proven" +"H06","T0","Host","Operate pinned unmodified official OMP through the shared adapter","Official OMP exposes RPC command/event seams","Fork bridge remains the released fallback","Gate 0: full","Gate 0: full","Gate 0: full","Official 17.0.6 passes lifecycle, steer/follow-up, approval, cancellation, and no-replay behavior on macOS ARM64 and native Linux x64/ARM64","Plan packaged cutover through the one shared adapter without creating a second adapter","PR #114 used the Lycaon fallback artifact; PR #124 corrects the boundary and retains per-platform evidence" "H07","T1","Host","Continue from OMP terminal into T4","Terminal command can hand off","Adds /continue-in-t4","Code: full","Code: partial","Code: partial","Mobile landing and failure recovery need proof","Safe CLI handoff plus deep link/session ID","Fork-specific command is appropriate" "H08","T1","Host","Continue from T4 into OMP terminal","Native terminal can resume a session","Same as upstream","Code: partial","Platform","Code: partial","No clear cross-platform handoff action","Explicit CLI handoff with copyable command","Do not emulate the terminal TUI inside the app" -"H09","T0","Host","Official OMP and fallback-bridge release alignment","Current original is 17.0.6","Released fallback bridge is pinned to 17.0.5","Code: partial","Code: partial","Code: partial","T4 now has a pinned official-OMP Gate 0 lane, but no generated compatibility snapshot or packaged cutover proof","Generate adapter compatibility snapshots and record fallback usage explicitly","Do not classify every fork difference as required product behavior" +"H09","T0","Host","Official OMP and fallback-bridge release alignment","Current original is 17.0.6","Released fallback bridge is pinned to 17.0.5","Code: partial","Code: partial","Code: partial","The generated Gate 0 snapshot now separates official 17.0.6 from the released Lycaon 17.0.5 fallback; packaged cutover is not proven","Retain exact pins and per-platform evidence, then replace the packaged fallback only after cutover proof","Missing stock seams are explicit: no ready watermark, live session entries, or durable command key" "H10","T0","Host","Source vs package vs public release truth","OMP publishes v17.0.6","Fork publishes pinned appserver tag","Main says v0.1.30; public release is v0.1.28","Main says v0.1.30; public release is v0.1.28","Implemented on main; not public release","Release evidence is contradictory","Track on-main, packaged-verified, and publicly-released separately","Public GitHub state checked 2026-07-20" "P01","T0","Projects","Project and session grouping","Sessions retain cwd and project metadata","Authority resolves project roots","Code: full","Code: full","Code: full","No major source gap","Keep projection in T4; roots remain OMP authority","Verify large libraries and moved projects" "P02","T0","Projects","Create project or start session in a project","CLI cwd and new session","Typed session.create and project roots","Code: full","Code: full","Code: full","Needs packaged proof","Existing typed command","Do not create a second project database" diff --git a/docs/T4_ARCHITECTURE.html b/docs/T4_ARCHITECTURE.html index 2473bb2..628abda 100644 --- a/docs/T4_ARCHITECTURE.html +++ b/docs/T4_ARCHITECTURE.html @@ -2089,10 +2089,11 @@

Gate 0: stock OMP seam

Prove the highest-risk dependency before building the control plane. A small T4-owned harness launches an unmodified official OMP release through omp --mode rpc --session <path>, waits for its ready - watermark, executes a prompt, steering or follow-up, approval response, + event, executes a prompt, steering or follow-up, approval response, cancellation, and a second prompt, then kills and respawns OMP against the same JSONL session on Linux. Transcript order and continuation must be - reconstructed from durable session entries and the ready watermark. + reconstructed from durable session entries. A ready watermark may improve + this later, but stock OMP 17.0.6 does not provide one.

This gate also resolves the dispatch crash window. Current stock OMP RPC @@ -2105,25 +2106,29 @@

Gate 0: stock OMP seam

execution.

- Implementation status (2026-07-20): the T4-owned supervisor now queries + Implementation status (2026-07-21): the T4-owned supervisor now queries stock OMP's bounded get_available_commands RPC, normalizes typed and verified headless operations into the shared catalog, and supplements commands omitted from headless discovery with a manifest pinned to the reviewed OMP source revision. Local catalog responses expose those classifications. Recognized terminal-only prompts such as /plan fail with structured terminal_only details - before any prompt is written to OMP stdin. The checksum-pinned - t4code-17.0.5-appserver-10 lifecycle harness now runs a - deterministic first prompt, kills OMP with SIGKILL, observes - the release's stale-lock fencing delay, respawns against the same JSONL, - validates the ready watermark against durable entries, and sends a second - prompt with the first turn restored in model context. It also verifies - that each live session_entry notification already exists on - disk. This slice passes on darwin-arm64; immutable - linux-arm64 and linux-x64 release assets are - pinned for the required Linux execution. Gate 0 remains open for that - Linux proof plus steering or follow-up, approval, cancellation, and - dispatch-crash ambiguity scenarios. + before any prompt is written to OMP stdin. The earlier lifecycle harness + used the released t4code-17.0.5-appserver-10 Lycaon fallback + artifact, despite its official-OMP name. The corrected harness separately + pins unmodified official OMP v17.0.6 and its per-platform + release hashes. On darwin-arm64 it now passes deterministic + lifecycle, SIGKILL and same-session recovery, steering, + follow-up, extension approval, and cancellation scenarios. Focused host + and client tests prove that a crash after stdin dispatch produces an + unknown outcome and never automatically replays the prompt. The checked + Gate 0 snapshot records three missing stock seams: no ready transcript + watermark, no live session_entry projection, and no durable + command key. T4 therefore reconciles official OMP's JSONL and fails closed + across ambiguity instead of pretending to provide exactly-once execution. + Native linux-arm64 and linux-x64 CI jobs pass the + same proof. Gate 0 is closed; packaged local and managed cutover remain a + separate release gate.

Gate 1: contract freeze

Freeze the shared contracts that every later workstream consumes:

diff --git a/packages/host-service/bin/official-omp-gate0.ts b/packages/host-service/bin/official-omp-gate0.ts index 6bee6cb..8bb1766 100755 --- a/packages/host-service/bin/official-omp-gate0.ts +++ b/packages/host-service/bin/official-omp-gate0.ts @@ -21,7 +21,7 @@ interface RuntimeArtifact { } interface RuntimeMatrix { - readonly verifiedRuntime: { + readonly officialRuntime: { readonly version: string; readonly sourceTag: string; readonly sourceCommit: string; @@ -54,6 +54,12 @@ interface TranscriptWatermark { interface DeterministicModel { readonly server: Bun.Server; readonly requests: string[][]; + readonly gateNextRequest: () => ModelGate; +} + +interface ModelGate { + readonly started: Promise; + readonly release: () => void; } interface RpcHarness { @@ -63,6 +69,10 @@ interface RpcHarness { readonly send: (frame: JsonMap) => void; } +interface LaunchOptions { + readonly extensionPath?: string; +} + function map(value: unknown, label: string): JsonMap { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as JsonMap; @@ -91,17 +101,17 @@ function decodeArtifact(value: unknown, label: string): RuntimeArtifact { function decodeRuntimeMatrix(value: unknown): RuntimeMatrix { const root = map(value, "compatibility matrix"); - const runtime = map(root.verifiedRuntime, "compatibility matrix.verifiedRuntime"); - const rawArtifacts = map(runtime.artifacts, "compatibility matrix.verifiedRuntime.artifacts"); + const runtime = map(root.officialRuntime, "compatibility matrix.officialRuntime"); + const rawArtifacts = map(runtime.artifacts, "compatibility matrix.officialRuntime.artifacts"); const artifacts: Record = {}; for (const [key, artifact] of Object.entries(rawArtifacts)) - artifacts[key] = decodeArtifact(artifact, `compatibility matrix.verifiedRuntime.artifacts.${key}`); - const version = text(runtime.version, "compatibility matrix.verifiedRuntime.version"); - const sourceTag = text(runtime.sourceTag, "compatibility matrix.verifiedRuntime.sourceTag"); - const sourceCommit = text(runtime.sourceCommit, "compatibility matrix.verifiedRuntime.sourceCommit"); - if (!/^\d+\.\d+\.\d+$/u.test(version)) throw new Error("verified runtime version is invalid"); - if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error("verified runtime source commit is invalid"); - return { verifiedRuntime: { version, sourceTag, sourceCommit, artifacts } }; + artifacts[key] = decodeArtifact(artifact, `compatibility matrix.officialRuntime.artifacts.${key}`); + const version = text(runtime.version, "compatibility matrix.officialRuntime.version"); + const sourceTag = text(runtime.sourceTag, "compatibility matrix.officialRuntime.sourceTag"); + const sourceCommit = text(runtime.sourceCommit, "compatibility matrix.officialRuntime.sourceCommit"); + if (!/^\d+\.\d+\.\d+$/u.test(version)) throw new Error("official runtime version is invalid"); + if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error("official runtime source commit is invalid"); + return { officialRuntime: { version, sourceTag, sourceCommit, artifacts } }; } function decodeRuntimeManifest(value: unknown): RuntimeManifest { @@ -131,7 +141,8 @@ async function readJson(path: string): Promise { return JSON.parse(await readFile(path, "utf8")); } -function decodeWatermark(frame: JsonMap, label: string): TranscriptWatermark { +function decodeWatermark(frame: JsonMap, label: string): TranscriptWatermark | null { + if (frame.transcriptWatermark === undefined) return null; const watermark = map(frame.transcriptWatermark, `${label}.transcriptWatermark`); const lastEntryId = watermark.lastEntryId; if (lastEntryId !== null && typeof lastEntryId !== "string") @@ -214,6 +225,12 @@ function requestMessages(body: JsonMap): string[] { function startDeterministicModel(): DeterministicModel { const requests: string[][] = []; + let nextGate: + | { + readonly started: ReturnType>; + readonly released: ReturnType>; + } + | undefined; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -224,6 +241,10 @@ function startDeterministicModel(): DeterministicModel { const body = map(await request.json(), "model request"); const messages = requestMessages(body); requests.push(messages); + const gate = nextGate; + nextGate = undefined; + gate?.started.resolve(messages); + if (gate) await gate.released.promise; const ordinal = requests.length; const model = typeof body.model === "string" ? body.model : "deterministic"; const id = `chatcmpl-t4-gate0-${ordinal}`; @@ -257,10 +278,29 @@ function startDeterministicModel(): DeterministicModel { return new Response(payload, { headers: { "content-type": "text/event-stream" } }); }, }); - return { server, requests }; + return { + server, + requests, + gateNextRequest: () => { + if (nextGate) throw new Error("a deterministic model request is already gated"); + const started = Promise.withResolvers(); + const released = Promise.withResolvers(); + nextGate = { started, released }; + return { started: started.promise, release: released.resolve }; + }, + }; } -function launchRpc(runtimePath: string, sessionPath: string, workspace: string, profile: string): RpcHarness { +function launchRpc( + runtimePath: string, + sessionPath: string, + workspace: string, + profile: string, + options: LaunchOptions = {}, +): RpcHarness { + const extensionArgs = options.extensionPath + ? ["--extension", options.extensionPath] + : ["--no-extensions"]; const child = Bun.spawn( [ runtimePath, @@ -273,7 +313,7 @@ function launchRpc(runtimePath: string, sessionPath: string, workspace: string, "--model", "gate0/deterministic", "--no-tools", - "--no-extensions", + ...extensionArgs, "--no-skills", "--no-rules", "--no-title", @@ -284,8 +324,6 @@ function launchRpc(runtimePath: string, sessionPath: string, workspace: string, ...process.env, PI_CODING_AGENT_DIR: profile, PI_NOTIFICATIONS: "off", - OMP_APP_RPC_INLINE_IMAGE_DATA: "omit", - OMP_APP_RPC_SESSION_ENTRIES: "1", }, stdin: "pipe", stdout: "pipe", @@ -327,11 +365,18 @@ function launchRpc(runtimePath: string, sessionPath: string, workspace: string, const waitFor = async (predicate: (frame: JsonMap) => boolean, label: string): Promise => { const deadline = Date.now() + FRAME_TIMEOUT_MS; const frames: JsonMap[] = []; - for (;;) { - const frame = await nextFrame(deadline); - frames.push(frame); - if (predicate(frame)) return frames; - if (Date.now() >= deadline) throw new Error(`timed out waiting for ${label}`); + try { + for (;;) { + const frame = await nextFrame(deadline); + frames.push(frame); + if (predicate(frame)) return frames; + if (Date.now() >= deadline) throw new Error("deadline reached"); + } + } catch (error) { + throw new Error( + `timed out waiting for ${label}; observed ${JSON.stringify(frames.map((frame) => frame.type))}`, + { cause: error }, + ); } }; @@ -354,19 +399,175 @@ function assertPromptTurn(frames: readonly JsonMap[], requestId: string): string }); } +function assertAccepted(frames: readonly JsonMap[], requestId: string, command: string): void { + const response = frames.find((frame) => frame.type === "response" && frame.id === requestId); + if (!response || response.command !== command || response.success !== true) + throw new Error(`${command} ${requestId} was not accepted`); +} + +async function waitForModelMessage(model: DeterministicModel, expected: string): Promise { + const deadline = Date.now() + FRAME_TIMEOUT_MS; + while (Date.now() < deadline) { + const request = model.requests.find((messages) => messages.some((message) => message.includes(expected))); + if (request) return request; + await Bun.sleep(10); + } + throw new Error(`model did not receive ${JSON.stringify(expected)}`); +} + +async function stopRpc(rpc: RpcHarness): Promise { + rpc.child.kill("SIGTERM"); + await rpc.child.exited; +} + +async function runQueuedMessageScenario(input: { + readonly behavior: "steer" | "follow_up"; + readonly runtimePath: string; + readonly root: string; + readonly workspace: string; + readonly profile: string; + readonly model: DeterministicModel; +}): Promise<{ requestObserved: boolean; accepted: boolean }> { + const label = input.behavior === "steer" ? "Steering correction" : "Follow-up request"; + const sessionPath = join(input.root, `${input.behavior}.jsonl`); + const rpc = launchRpc(input.runtimePath, sessionPath, input.workspace, input.profile); + let stopped = false; + const gate = input.model.gateNextRequest(); + try { + await rpc.waitFor((frame) => frame.type === "ready", `${input.behavior} ready`); + rpc.send({ type: "prompt", id: `${input.behavior}-base`, message: `${input.behavior} base prompt` }); + const promptAck = await rpc.waitFor( + (frame) => frame.type === "response" && frame.id === `${input.behavior}-base`, + `${input.behavior} base prompt acceptance`, + ); + assertAccepted(promptAck, `${input.behavior}-base`, "prompt"); + await gate.started; + rpc.send({ type: input.behavior, id: `${input.behavior}-queued`, message: label }); + const queueAck = await rpc.waitFor( + (frame) => frame.type === "response" && frame.id === `${input.behavior}-queued`, + `${input.behavior} acceptance`, + ); + assertAccepted(queueAck, `${input.behavior}-queued`, input.behavior); + gate.release(); + await waitForModelMessage(input.model, label); + const entries = await waitForMessages(sessionPath, 4); + if (!transcriptMessages(entries).some((message) => message.text.includes(label))) + throw new Error(`${input.behavior} message was not durable`); + await stopRpc(rpc); + stopped = true; + return { requestObserved: true, accepted: true }; + } finally { + gate.release(); + if (!stopped) { + rpc.child.kill("SIGKILL"); + await rpc.child.exited.catch(() => undefined); + } + } +} + +async function runCancellationScenario(input: { + readonly runtimePath: string; + readonly root: string; + readonly workspace: string; + readonly profile: string; + readonly model: DeterministicModel; +}): Promise<{ accepted: boolean; agentSettled: boolean }> { + const rpc = launchRpc(input.runtimePath, join(input.root, "cancel.jsonl"), input.workspace, input.profile); + let stopped = false; + const gate = input.model.gateNextRequest(); + try { + await rpc.waitFor((frame) => frame.type === "ready", "cancellation ready"); + rpc.send({ type: "prompt", id: "cancel-base", message: "Cancellation base prompt" }); + const promptAck = await rpc.waitFor( + (frame) => frame.type === "response" && frame.id === "cancel-base", + "cancellation prompt acceptance", + ); + assertAccepted(promptAck, "cancel-base", "prompt"); + await gate.started; + rpc.send({ type: "abort", id: "cancel-abort" }); + const abortFrames = await rpc.waitFor( + (frame) => frame.type === "response" && frame.id === "cancel-abort", + "abort acceptance", + ); + assertAccepted(abortFrames, "cancel-abort", "abort"); + const agentSettled = abortFrames.some((frame) => frame.type === "agent_end"); + if (!agentSettled) throw new Error("abort acknowledgment arrived before agent settlement"); + gate.release(); + await stopRpc(rpc); + stopped = true; + return { accepted: true, agentSettled }; + } finally { + gate.release(); + if (!stopped) { + rpc.child.kill("SIGKILL"); + await rpc.child.exited.catch(() => undefined); + } + } +} + +async function runApprovalScenario(input: { + readonly runtimePath: string; + readonly root: string; + readonly workspace: string; + readonly profile: string; +}): Promise<{ requestObserved: boolean; approvedValueReturned: boolean }> { + const extensionPath = join(input.root, "gate0-approval.ts"); + await writeFile( + extensionPath, + `export default function (pi) {\n pi.registerCommand("gate0-confirm", {\n description: "Gate 0 confirmation proof",\n handler: async (_args, ctx) => {\n const confirmed = await ctx.ui.confirm("Gate 0 approval", "Approve this deterministic request?");\n ctx.ui.notify(\`gate0-confirmed=\${confirmed}\`, "info");\n },\n });\n}\n`, + ); + const rpc = launchRpc( + input.runtimePath, + join(input.root, "approval.jsonl"), + input.workspace, + input.profile, + { extensionPath }, + ); + let stopped = false; + try { + await rpc.waitFor((frame) => frame.type === "ready", "approval ready"); + rpc.send({ type: "prompt", id: "approval-prompt", message: "/gate0-confirm" }); + const requestFrames = await rpc.waitFor( + (frame) => frame.type === "extension_ui_request" && frame.method === "confirm", + "extension confirmation request", + ); + const request = requestFrames.at(-1)!; + const requestId = text(request.id, "extension confirmation request id"); + rpc.send({ type: "extension_ui_response", id: requestId, confirmed: true }); + const notifyFrames = await rpc.waitFor( + (frame) => + frame.type === "extension_ui_request" && + frame.method === "notify" && + frame.message === "gate0-confirmed=true", + "extension confirmation result", + ); + await stopRpc(rpc); + stopped = true; + return { + requestObserved: request.method === "confirm", + approvedValueReturned: notifyFrames.at(-1)?.message === "gate0-confirmed=true", + }; + } finally { + if (!stopped) { + rpc.child.kill("SIGKILL"); + await rpc.child.exited.catch(() => undefined); + } + } +} + async function verifyRuntime(repoRoot: string): Promise { const matrix = decodeRuntimeMatrix(await readJson(join(repoRoot, "compat", "omp-app-matrix.json"))); - const manifestPath = join(repoRoot, ".artifacts", "omp-runtime", "manifest.json"); + const manifestPath = join(repoRoot, ".artifacts", "omp-runtime-official", "manifest.json"); const manifest = decodeRuntimeManifest(await readJson(manifestPath)); const key = `${process.platform}-${process.arch}`; - const artifact = matrix.verifiedRuntime.artifacts[key]; + const artifact = matrix.officialRuntime.artifacts[key]; if (!artifact) throw new Error(`verified OMP runtime has no ${key} artifact`); - const runtimePath = join(repoRoot, ".artifacts", "omp-runtime", manifest.executable); + const runtimePath = join(repoRoot, ".artifacts", "omp-runtime-official", manifest.executable); const runtimeStat = await stat(runtimePath); const digest = await sha256(runtimePath); if ( manifest.version !== 1 || - manifest.tag !== matrix.verifiedRuntime.sourceTag || + manifest.tag !== matrix.officialRuntime.sourceTag || manifest.platform !== process.platform || manifest.arch !== process.arch || basename(runtimePath) !== manifest.executable || @@ -383,8 +584,8 @@ async function verifyRuntime(repoRoot: string): Promise { versionProcess.exited, ]); if (versionExit !== 0) throw new Error(`OMP version check failed: ${versionError.trim()}`); - if (!version.includes(matrix.verifiedRuntime.version)) - throw new Error(`OMP version ${JSON.stringify(version.trim())} does not match ${matrix.verifiedRuntime.version}`); + if (!version.includes(matrix.officialRuntime.version)) + throw new Error(`OMP version ${JSON.stringify(version.trim())} does not match ${matrix.officialRuntime.version}`); return { path: runtimePath, matrix, manifest, version: version.trim() }; } @@ -409,7 +610,7 @@ async function main(): Promise { active = launchRpc(runtime.path, sessionPath, workspace, profile); const initialFrames = await active.waitFor((frame) => frame.type === "ready", "initial ready watermark"); const initialWatermark = decodeWatermark(initialFrames.at(-1)!, "initial ready"); - assertWatermark("initial ready watermark", initialWatermark, await readSession(sessionPath)); + if (initialWatermark) assertWatermark("initial ready watermark", initialWatermark, await readSession(sessionPath)); active.send({ type: "prompt", id: "gate0-prompt-1", message: "First prompt" }); const firstFrames = await active.waitFor((frame) => frame.type === "agent_end", "first prompt completion"); const firstLiveEntryIds = assertPromptTurn(firstFrames, "gate0-prompt-1"); @@ -428,7 +629,7 @@ async function main(): Promise { active = launchRpc(runtime.path, sessionPath, workspace, profile); const restartFrames = await active.waitFor((frame) => frame.type === "ready", "restart ready watermark"); const restartWatermark = decodeWatermark(restartFrames.at(-1)!, "restart ready"); - assertWatermark("restart ready watermark", restartWatermark, restartEntries); + if (restartWatermark) assertWatermark("restart ready watermark", restartWatermark, restartEntries); active.send({ type: "prompt", id: "gate0-prompt-2", message: "Second prompt" }); const secondFrames = await active.waitFor((frame) => frame.type === "agent_end", "second prompt completion"); const secondLiveEntryIds = assertPromptTurn(secondFrames, "gate0-prompt-2"); @@ -455,28 +656,66 @@ async function main(): Promise { active.child.kill("SIGTERM"); await active.child.exited; active = undefined; - console.log( - JSON.stringify( - { - runtime: { - version: runtime.version, - tag: runtime.matrix.verifiedRuntime.sourceTag, - commit: runtime.matrix.verifiedRuntime.sourceCommit, - sha256: runtime.manifest.sha256, - }, - initialWatermark, - restartWatermark, + const steer = await runQueuedMessageScenario({ + behavior: "steer", + runtimePath: runtime.path, + root, + workspace, + profile, + model, + }); + const followUp = await runQueuedMessageScenario({ + behavior: "follow_up", + runtimePath: runtime.path, + root, + workspace, + profile, + model, + }); + const approval = await runApprovalScenario({ runtimePath: runtime.path, root, workspace, profile }); + const cancellation = await runCancellationScenario({ + runtimePath: runtime.path, + root, + workspace, + profile, + model, + }); + const result = { + schemaVersion: 1, + runtime: { + version: runtime.version, + tag: runtime.matrix.officialRuntime.sourceTag, + commit: runtime.matrix.officialRuntime.sourceCommit, + sha256: runtime.manifest.sha256, + }, + platform: { os: process.platform, arch: process.arch }, + scenarios: { + lifecycle: { durableMessages: messages.length, - liveSessionEntries: firstLiveEntryIds.length + secondLiveEntryIds.length, - modelRequests: model.requests.map((messagesForRequest) => messagesForRequest.length), crashSignal: "SIGKILL", lockRecoveryWaitMs, resumedSameSession: true, }, - null, - 2, - ), + steer, + followUp, + approval, + cancellation, + }, + observedStockSeams: { + readyTranscriptWatermark: initialWatermark !== null && restartWatermark !== null, + liveSessionEntries: firstLiveEntryIds.length + secondLiveEntryIds.length > 0, + durableCommandKey: false, + }, + modelRequestCount: model.requests.length, + passed: true, + }; + const evidenceRoot = join(repoRoot, "artifacts", "official-omp-gate0"); + await mkdir(evidenceRoot, { recursive: true }); + await writeFile( + join(evidenceRoot, `${process.platform}-${process.arch}.json`), + `${JSON.stringify(result, null, 2)}\n`, ); + console.log(JSON.stringify(result, null, 2)); } finally { if (active) { active.child.kill("SIGKILL"); diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 141c847..f607c16 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -12,7 +12,7 @@ "build": "bun run typecheck", "check": "bun run typecheck", "test": "bun test test", - "verify:official-omp-lifecycle": "node ../../scripts/stage-omp-runtime.mjs && bun run bin/official-omp-gate0.ts", + "verify:official-omp-lifecycle": "node ../../scripts/stage-omp-runtime.mjs --runtime official && bun run bin/official-omp-gate0.ts", "typecheck": "tsgo -p tsconfig.json --noEmit" }, "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@t4-code/host-wire": "workspace:*" }, diff --git a/packages/host-service/test/hardening.test.ts b/packages/host-service/test/hardening.test.ts index 924cab9..4916c63 100644 --- a/packages/host-service/test/hardening.test.ts +++ b/packages/host-service/test/hardening.test.ts @@ -823,6 +823,50 @@ describe("child supervision", () => { expect(writes).toEqual([]); supervisor.stop(); }); + test("a child crash after command dispatch rejects the unknown outcome without replay", async () => { + const written = Promise.withResolvers(); + const exited = Promise.withResolvers(); + const writes: Record[] = []; + const crashErrors: Error[] = []; + let spawnCount = 0; + const child: ChildHandle = { + stdin: { + write: data => { + writes.push(JSON.parse(data) as Record); + written.resolve(); + }, + }, + stdout: (async function* () { + yield `${JSON.stringify({ type: "ready" })}\n`; + await written.promise; + exited.resolve(137); + })(), + stderr: (async function* () {})(), + exited: exited.promise, + kill: () => exited.resolve(137), + }; + const supervisor = new RpcChildSupervisor( + { + spawn: () => { + spawnCount += 1; + return child; + }, + argv: path => ["omp", "--mode", "rpc", "--session", path], + }, + record("s"), + { entry: () => {}, event: () => {}, crashed: error => crashErrors.push(error) }, + ); + await supervisor.start(); + let internalId: string | undefined; + await expect(supervisor.prompt("outer", "run once", undefined, id => (internalId = id))).rejects.toThrow( + /rpc child (?:stdout EOF|exited)/u, + ); + expect(internalId).toBe("outer:1"); + expect(writes).toEqual([{ type: "prompt", message: "run once", id: "outer:1" }]); + expect(supervisor.hasPendingCalls()).toBe(false); + expect(spawnCount).toBe(1); + expect(crashErrors).toHaveLength(1); + }); test("buffered child frames are discarded after an explicit stop", async () => { const releaseBuffered = Promise.withResolvers(); const bufferedDrained = Promise.withResolvers(); diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index dd78777..4a5d9ac 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -19,6 +19,7 @@ export const RELEASE_CONTRACT_PATHS = [ "apps/site/src/docs/content.ts", "apps/site/src/release.ts", "apps/web/src/platform/browser-shell-port.ts", + "compat/official-omp-gate0.json", "compat/omp-app-matrix.json", "docs/CURRENT_RELEASE_NOTES.md", "docs/MACOS_SIGNING.md", @@ -260,6 +261,98 @@ function validateRuntimeMetadata(value, label, matrixPath, errors) { }); } +function validateOfficialRuntimeMetadata(value, matrixPath, errors) { + const prefix = `${matrixPath} official runtime`; + const version = value?.version; + const sourceCommit = value?.sourceCommit; + if (value?.package !== "omp") errors.push(`${prefix} package must be omp`); + if (typeof version !== "string" || !VERSION_PATTERN.test(version)) { + errors.push(`${prefix} version must be a stable x.y.z version`); + } + if (value?.sourceRepository !== OMP_UPSTREAM_REPOSITORY) { + errors.push(`${prefix} repository must be ${OMP_UPSTREAM_REPOSITORY}`); + } + if (typeof sourceCommit !== "string" || !SHA_PATTERN.test(sourceCommit)) { + errors.push(`${prefix} commit must be a lowercase 40-character Git SHA`); + } + if (value?.sourceUrl !== `${OMP_UPSTREAM_REPOSITORY}/commit/${sourceCommit ?? ""}`) { + errors.push(`${prefix} URL must match its source commit`); + } + if (typeof version === "string" && value?.sourceTag !== `v${version}`) { + errors.push(`${prefix} tag must be v${version}`); + } + const expectedArtifacts = { + "darwin-arm64": "omp-darwin-arm64", + "darwin-x64": "omp-darwin-x64", + "linux-arm64": "omp-linux-arm64", + "linux-x64": "omp-linux-x64", + "win32-x64": "omp-windows-x64.exe", + }; + const artifacts = value?.artifacts; + if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts)) { + errors.push(`${prefix} artifacts must be an object`); + return; + } + const actualKeys = Object.keys(artifacts).sort(); + const expectedKeys = Object.keys(expectedArtifacts).sort(); + if (!isDeepStrictEqual(actualKeys, expectedKeys)) { + errors.push(`${prefix} artifacts must pin ${expectedKeys.join(", ")}`); + } + for (const [platform, expectedName] of Object.entries(expectedArtifacts)) { + const artifact = artifacts[platform]; + if (artifact?.name !== expectedName) errors.push(`${prefix} ${platform} artifact name must be ${expectedName}`); + if (!Number.isSafeInteger(artifact?.size) || artifact.size <= 0) { + errors.push(`${prefix} ${platform} artifact size must be a positive integer`); + } + if (typeof artifact?.sha256 !== "string" || !SHA256_PATTERN.test(artifact.sha256)) { + errors.push(`${prefix} ${platform} artifact SHA-256 must be a lowercase digest`); + } + } +} + +function validateOfficialGate0Snapshot(snapshot, officialRuntime, path, errors) { + if (snapshot?.schemaVersion !== 1) errors.push(`${path} schemaVersion must be 1`); + if (snapshot?.gate !== "official-omp-gate0") errors.push(`${path} gate must be official-omp-gate0`); + for (const [field, expected] of [ + ["version", officialRuntime?.version], + ["tag", officialRuntime?.sourceTag], + ["commit", officialRuntime?.sourceCommit], + ]) { + if (snapshot?.runtime?.[field] !== expected) { + errors.push(`${path} runtime ${field} must match compat/omp-app-matrix.json officialRuntime`); + } + } + const requiredPlatforms = ["darwin-arm64", "linux-x64", "linux-arm64"]; + if (!isDeepStrictEqual(snapshot?.requiredPlatforms, requiredPlatforms)) { + errors.push(`${path} requiredPlatforms must cover macOS ARM64 and Linux x64/ARM64`); + } + const requiredScenarios = [ + "lifecycle", + "crash-resume", + "steer", + "follow-up", + "approval", + "cancellation", + "crash-after-dispatch-no-replay", + ]; + if (!isDeepStrictEqual(snapshot?.requiredScenarios, requiredScenarios)) { + errors.push(`${path} requiredScenarios must match the Gate 0 proof contract`); + } + for (const capability of ["prompt", "steer", "followUp", "approvalRoundTrip", "abort", "sessionResume"]) { + if (snapshot?.officialRpcSupport?.[capability] !== true) { + errors.push(`${path} officialRpcSupport.${capability} must be true`); + } + } + for (const seam of ["readyTranscriptWatermark", "liveSessionEntries", "durableCommandKey"]) { + if (snapshot?.missingOfficialSeams?.[seam] !== true) { + errors.push(`${path} missingOfficialSeams.${seam} must remain explicit`); + } + } + if (snapshot?.t4Policy?.ambiguousDispatch !== "outcome-unknown-no-auto-replay") { + errors.push(`${path} ambiguous dispatch policy must fail closed without automatic replay`); + } +} + export function collectReleaseConsistencyErrors(files, releaseTag) { const errors = []; const rootManifest = parseJson(files, "package.json", errors); @@ -356,6 +449,10 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { const matrixPath = "compat/omp-app-matrix.json"; const matrix = parseJson(files, matrixPath, errors); + validateOfficialRuntimeMetadata(matrix?.officialRuntime, matrixPath, errors); + const officialGatePath = "compat/official-omp-gate0.json"; + const officialGate = parseJson(files, officialGatePath, errors); + validateOfficialGate0Snapshot(officialGate, matrix?.officialRuntime, officialGatePath, errors); if (matrix?.desktop?.version !== version) { errors.push(`${matrixPath} desktop version must be ${version}`); } @@ -813,6 +910,10 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "run: pnpm test:legacy-bridge-continuity", "path: artifacts/legacy-bridge-continuity/", "if-no-files-found: error", + "official-omp-gate0:", + "runner: ubuntu-24.04-arm", + "run: pnpm --filter @t4-code/host-service verify:official-omp-lifecycle", + "path: artifacts/official-omp-gate0/${{ matrix.platform }}.json", "tooling:", "cluster:", "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", @@ -831,7 +932,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", "name: verify", "if: ${{ always() }}", - "needs: [changes, core, legacy-bridge-continuity, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", 'test "$CHANGES_RESULT" = success', 'test "$CORE_RESULT" = success', "for result in \\", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 8acccbe..4eab710 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -92,6 +92,27 @@ test("promotes the verified runtime into the product release", () => { assert.deepEqual(matrix.publishedRuntime, matrix.verifiedRuntime); }); +test("pins official OMP artifacts and the Gate 0 proof contract", () => { + const officialDrift = changedRuntime("officialRuntime", (runtime) => { + runtime.artifacts["linux-arm64"].sha256 = "invalid"; + }); + assert.ok( + collectReleaseConsistencyErrors(officialDrift).some((error) => + error.includes("official runtime linux-arm64 artifact SHA-256"), + ), + ); + + const snapshotDrift = changed("compat/official-omp-gate0.json", (text) => { + const snapshot = JSON.parse(text); + snapshot.runtime.commit = "0".repeat(40); + snapshot.requiredScenarios = snapshot.requiredScenarios.filter((item) => item !== "approval"); + return JSON.stringify(snapshot); + }); + const errors = collectReleaseConsistencyErrors(snapshotDrift); + assert.ok(errors.some((error) => error.includes("runtime commit must match"))); + assert.ok(errors.some((error) => error.includes("requiredScenarios must match"))); +}); + test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => @@ -250,14 +271,14 @@ test("rejects updater channel, stable manifest, and publication-contract drift", ".github/workflows/ci.yml", (text) => text.replace( - "needs: [changes, core, legacy-bridge-continuity, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", "needs: [changes, core, tooling, android-debug]", ), ], [ ".github/workflows/ci.yml", (text) => - text.replace( + text.replaceAll( "ref: ${{ github.event.pull_request.head.sha || github.sha }}", "ref: ${{ github.ref }}", ), @@ -555,7 +576,7 @@ test("deploys release site source only after artifact publication", () => { assert.ok(ciWorkflow.includes("if: ${{ always() }}")); assert.ok( ciWorkflow.includes( - "needs: [changes, core, legacy-bridge-continuity, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", ), ); assert.ok(ciWorkflow.includes('test "$CHANGES_RESULT" = success')); diff --git a/scripts/ci-paths.mjs b/scripts/ci-paths.mjs index 1f4dc3a..0d194e1 100755 --- a/scripts/ci-paths.mjs +++ b/scripts/ci-paths.mjs @@ -28,6 +28,13 @@ const GROUP_PATTERNS = Object.freeze({ /^packages\/host-(?:service|wire)\/(?:src\/|package\.json$)/u, /^scripts\/cluster-ci\//u, ], + official_omp_gate0: [ + /^\.github\/workflows\/ci\.yml$/u, + /^compat\/(?:official-omp-gate0|omp-app-matrix)\.json$/u, + /^docs\/(?:OMP_T4_CAPABILITY_AUDIT\.md|OMP_T4_CAPABILITY_TRACKER\.csv|T4_ARCHITECTURE\.html)$/u, + /^packages\/host-service\/(?:bin\/official-omp-gate0\.ts|package\.json)$/u, + /^scripts\/stage-omp-runtime\.mjs$/u, + ], tooling: [ /^\.github\//u, /^compat\//u, diff --git a/scripts/ci-paths.test.mjs b/scripts/ci-paths.test.mjs index 87c04c5..92f0ae6 100644 --- a/scripts/ci-paths.test.mjs +++ b/scripts/ci-paths.test.mjs @@ -5,6 +5,7 @@ import { classifyCiPaths, formatGitHubOutputs } from "./ci-paths.mjs"; const none = { continuity: false, cluster: false, + official_omp_gate0: false, tooling: false, android_debug: false, flutter: false, @@ -21,15 +22,20 @@ test("host runtime source runs host gates without unrelated platform builds", () }); }); -test("lifecycle harness and architecture docs run tooling only", () => { +test("official lifecycle inputs run their native proof and tooling", () => { assert.deepEqual( classifyCiPaths([ "packages/host-service/bin/official-omp-gate0.ts", "docs/T4_ARCHITECTURE.html", "compat/omp-app-matrix.json", ]), - { ...none, tooling: true }, + { ...none, official_omp_gate0: true, tooling: true }, ); + assert.deepEqual(classifyCiPaths(["docs/OMP_T4_CAPABILITY_TRACKER.csv"]), { + ...none, + official_omp_gate0: true, + tooling: true, + }); }); test("cluster implementation changes run the cluster gate", () => { @@ -52,6 +58,7 @@ test("host wire changes run every dependent client and continuity gate", () => { assert.deepEqual(classifyCiPaths(["packages/host-wire/src/command.ts"]), { continuity: true, cluster: true, + official_omp_gate0: false, tooling: true, android_debug: true, flutter: true, @@ -80,6 +87,7 @@ test("dependency graph changes conservatively run every leg", () => { assert.deepEqual(classifyCiPaths([path]), { continuity: true, cluster: true, + official_omp_gate0: true, tooling: true, android_debug: true, flutter: true, @@ -93,6 +101,7 @@ test("workflow changes run tooling on the PR and the full matrix after merge", ( assert.deepEqual(classifyCiPaths([".github/workflows/ci.yml"]), { ...none, cluster: true, + official_omp_gate0: true, tooling: true, }); }); @@ -101,6 +110,6 @@ test("paths are normalized and GitHub outputs are stable", () => { const result = classifyCiPaths(["./apps\\flutter\\pubspec.yaml", "./apps/flutter/pubspec.yaml"]); assert.equal( formatGitHubOutputs(result), - "continuity=false\ncluster=false\ntooling=false\nandroid_debug=false\nflutter=true\nflutter_android=true\nflutter_apple=true\n", + "continuity=false\ncluster=false\nofficial_omp_gate0=false\ntooling=false\nandroid_debug=false\nflutter=true\nflutter_android=true\nflutter_apple=true\n", ); }); diff --git a/scripts/stage-omp-runtime.mjs b/scripts/stage-omp-runtime.mjs index b4046ec..7e37a03 100644 --- a/scripts/stage-omp-runtime.mjs +++ b/scripts/stage-omp-runtime.mjs @@ -16,12 +16,16 @@ const option = (name) => { const platform = option("platform") ?? process.platform; const arch = option("arch") ?? process.arch; const key = `${platform}-${arch}`; -const runtime = matrix.verifiedRuntime; +const runtimeKind = option("runtime") ?? "verified"; +if (runtimeKind !== "verified" && runtimeKind !== "official") { + throw new Error("--runtime must be official or verified"); +} +const runtime = runtimeKind === "official" ? matrix.officialRuntime : matrix.verifiedRuntime; const artifact = runtime?.artifacts?.[key]; if (!artifact || !/^[a-z0-9][a-z0-9._-]{1,80}$/u.test(artifact.name) || !/^[0-9a-f]{64}$/u.test(artifact.sha256)) { - throw new Error(`compat/omp-app-matrix.json has no valid ${key} runtime artifact`); + throw new Error(`compat/omp-app-matrix.json has no valid ${runtimeKind} ${key} runtime artifact`); } -const outputRoot = join(repoRoot, ".artifacts", "omp-runtime"); +const outputRoot = join(repoRoot, ".artifacts", runtimeKind === "official" ? "omp-runtime-official" : "omp-runtime"); const output = join(outputRoot, "omp"); const temporary = `${output}.partial-${process.pid}`; const url = `${runtime.sourceRepository}/releases/download/${runtime.sourceTag}/${artifact.name}`; @@ -54,4 +58,4 @@ await writeFile( `${JSON.stringify({ version: 1, tag: runtime.sourceTag, platform, arch, executable: basename(output), size: artifact.size, sha256: artifact.sha256 }, null, 2)}\n`, { mode: 0o600 }, ); -console.log(`staged ${runtime.sourceTag} ${key} runtime`); +console.log(`staged ${runtimeKind} ${runtime.sourceTag} ${key} runtime`);