From eb28b3dc29c543f8601fccff0e02fb7032555b3b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:45:25 -0700 Subject: [PATCH 01/15] docs: plan REST Codex terminal identity publication --- ...26-07-29-rest-terminal-session-identity.md | 1007 +++++++++++++++++ 1 file changed, 1007 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md diff --git a/docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md b/docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md new file mode 100644 index 000000000..15a71c0f9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md @@ -0,0 +1,1007 @@ +# REST Codex Terminal Identity Publication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `subagent-driven-development` or `executing-plans` to implement this plan +> task-by-task. Preserve the RED evidence before production edits, use a fresh +> implementer for each task, and run spec and quality review after every task. + +**Goal:** Fix the duplicate History-sidebar row caused by an explicitly resumed +Codex terminal created through `POST /api/tabs`: the terminal registry row, the +shared in-memory terminal identity, the durable pane-ledger binding, and the +browser's cached terminal-directory row must converge on the same terminal and +session through the create's ordered invalidation sequence. + +**Architecture:** Add an atomic publication primitive to +`TerminalRegistry`. It inserts the new terminal row and invokes a synchronous, +in-memory identity hook while holding the registry lock, then opens a +per-create exit gate. No inventory reader, `Created` activity observer, or exit +retirement can observe the row between insertion and identity publication. +Durable pane-ledger I/O remains a separate awaited future outside the registry +lock. The existing injected identity bridge will expose an async ownership +preflight, synchronous publication, and awaited typed durability phases. The +REST path will use the new authority behavior only for a validated, nonempty, +explicit Codex `sessionRef`; it will not infer authority from +`resumeSessionId`. After settlement, ordinary delivery will mirror the +WebSocket create order: `ui.command`, unconditional shared-revision +`terminals.changed`, then canonical `terminal.meta.updated` when identity was +published. Deferred delivery omits the `ui.command` broadcast but emits the +same invalidations before returning the targeted command. The WebSocket path +will move its existing in-memory identity upsert into the same atomic +publication primitive, closing the same latent race without changing its +identity rules. + +**Persistence claim:** An awaited successful pane-ledger write is reloaded +after a normal process/server restart. This plan does not claim that the first +write survives every power-loss, filesystem, controller-cache, or storage +failure scenario. + +**Tech stack:** Rust 2021, Axum, Tokio, `freshell-terminal`, +`freshell-freshagent`, `freshell-server`, `freshell-ws`, React 18, Redux +Toolkit, Vitest, Testing Library. + +## Global Constraints + +- Work only in + `/home/dan/code/freshell/.worktrees/rest-terminal-session-identity` on branch + `fix/rest-terminal-session-identity`, forked from + `4c04dc9c1d5bd603ac6bb00540cfbafed675a78b`. +- The exact base suite is already green: 9,585 tests passed, 27 skipped, and + zero failed. Preserve that evidence in the final report. +- Use red/green/refactor TDD. The combined route regression in Task 1 must run + and produce the exact runtime RED tuple before any production wiring changes. +- Scope the new REST authority rule to `mode == "codex"` plus the already + validated, explicit, nonempty `accepted_session_ref` whose provider is + `"codex"`. +- Do not derive authoritative identity from raw `resumeSessionId`, from the + existing pane-payload promotion heuristic, from locator output, or from a + CLI extension manifest. +- Do not change pending-marker behavior, Claude/OpenCode/Amplifier identity + semantics, extension/provider identity semantics, or sidebar selector + semantics. The one intentional cross-mode parity change is an unconditional + `terminals.changed` after every successful REST terminal create. +- Preserve existing pane payload compatibility: the pre-existing + `resumeSessionId`-to-`paneContent.sessionRef` promotion may remain for UI + payloads, but it must not enter the new authoritative publication path. +- `freshell-freshagent` must not depend on `freshell-ws`; the existing injected + bridge remains the crate-cycle boundary. +- Synchronous publication must do no filesystem I/O, must not await, and must + not re-enter `TerminalRegistry`. Pane-ledger I/O must remain on + `spawn_blocking` and outside the registry lock. +- A durable write degradation or I/O error must not kill a successfully + spawned terminal or turn its REST response into an error. It must return + HTTP 200 and broadcast exactly one `durability.degraded` frame with a + machine-distinct reason. +- Protect an already-live fresh Codex owner. Check exact liveness immediately + before spawn; when live, do not publish terminal identity and do not + overwrite its `paneKind:"fresh-agent"` row. +- Preserve D7/D8 single-writer protection, spawn-gate ownership, managed Codex + adoption, locator arming, create dedupe, restore-key replay, pane/tab + bookkeeping, and PTY group-kill discipline. +- Do not touch live port 3002, production state, the preserved evidence bundle, + or user processes. +- Do not create a PR, merge, deploy, restart the production server, or remove + the worktree without separate explicit user approval. + +## Load-Bearing Validation Ledger + +The load-bearing finder, strategist, validators, and controller checked the +plan against the current code and the preserved incident shape. These results +are requirements, not suggestions. + +| ID | Assumption tested | Evidence / cheapest reliable method | Verdict | Plan consequence | +| --- | --- | --- | --- | --- | +| LB-1 | The duplicate is only a client-selector problem. | Inspected `POST /api/tabs`, `/api/terminals`, `TerminalIdentityRegistry`, `PaneLedger`, and `sidebarSelectors`. The pane had `codex:` while the live directory row lacked `sessionRef`, so the selector correctly emitted its documented `codex:terminal:` fallback. | **Falsified** | Fix the server publication path; do not patch selector dedupe. | +| LB-2 | An identity upsert after `TerminalRegistry::create` is sufficiently ordered. | Inspected `TerminalRegistry::create`: it inserts the row, increments revision, drops the registry lock, logs/notifies `Created`, and returns before caller-owned identity work. A concurrent directory/inventory read can observe the row first. | **Falsified** | Add atomic row-plus-identity publication under the registry lock. | +| LB-3 | The existing PTY exit hook cannot outrun post-create identity publication. | Inspected `PtyTerminal::spawn_with_sink` and current REST/WS exit hooks. An immediately exiting child can call retirement before the caller publishes identity, leaving a later upsert incorrectly live. | **Falsified** | Add a per-create exit gate and deterministic instant-exit regression. | +| LB-4 | A raw `resumeSessionId` is equivalent authority to a validated explicit `sessionRef`. | Inspected `derive_resume_identity` and the separate pane-payload promotion heuristic. Raw resume values have compatibility and provider-specific meanings not covered by this incident's acceptance proof. | **Falsified** | Gate the new authority path strictly to explicit accepted Codex `sessionRef`; preserve payload behavior without using it as authority. | +| LB-5 | The incident requires pending markers or changes for providers other than Codex. | Preserved evidence and route/code inspection identify an explicit recovered Codex session. No pending, Claude, OpenCode, Amplifier, or extension failure is needed to reproduce it. | **Falsified** | Delete pending/provider expansion from scope and add negative contract tests. | +| LB-6 | Rewriting the old pane-ledger owner is always safe. | Inspected `PaneLedger::record_binding`: it will replace a row keyed by `(provider,sessionId)`, including a `paneKind:"fresh-agent"` row. A stale terminal owner should be replaced, but an actually-live fresh Codex owner must not be. | **Conditionally verified** | Add a weak fresh-Codex liveness seam and typed `LiveFreshAgentOwner` degradation; preserve the live fresh-agent row. | +| LB-7 | A disabled ledger is indistinguishable from a successful durable write. | `PaneLedger::is_enabled` exists, while current disabled writes return `Ok(())`. | **Falsified** | Return typed `Degraded(LedgerUnavailable)` instead of calling it durable. | +| LB-8 | Awaiting `record_binding` proves only an in-memory update. | `PaneLedger` writes the row file before its write-through index; constructing a new ledger over the same temporary root reloads it. | **Verified for restart persistence** | Add enabled-ledger disk-reload coverage. Do not promise universal power-loss durability. | +| LB-9 | The browser needs a selector patch to recover a cached unidentified terminal row. | Inspected `terminal-invalidation-handler`, `fetchTerminalDirectoryWindow`, and the directory reducer. `terminals.changed` plus `terminal.meta.updated` debounce to one refresh; the GET accepts the cached revision but still returns current items, and non-append replacement removes the stale row even when the derived revision is unchanged. | **Verified false** | Emit unconditional `terminals.changed` for every successful REST terminal create, add canonical metadata only when identity is published, add a real Redux/App same-revision cached-row regression, and leave selectors unchanged. | +| LB-10 | Deferred restore delivery can skip terminal invalidations because no `ui.command` is broadcast. | Inspected `create_terminal_or_content_tab_deferred`: the UI command is returned to a targeted delivery path, so unrelated live clients otherwise receive no create signal. | **Falsified** | Emit canonical metadata and `terminals.changed` for both broadcast and deferred delivery before the HTTP response. | +| LB-11 | This fix can make terminal-vs-fresh-agent admission fully atomic. | The terminal registry lock, async fresh-agent session maps, and pane-ledger lock are separate domains. A weak liveness check can protect an owner already live at the check, but a concurrent cross-kind create can begin after it. | **Falsified / out of incident scope** | Document the residual concurrent cross-kind admission race; do not claim it is solved here. | + +### Resulting scope corrections + +1. The original plan's raw-resume canonicalization, new pending-marker writes, + provider-wide behavior, and selector fallback were removed. +2. The exact combined route regression moved ahead of all production changes. +3. Atomic registry publication and an early-exit gate became prerequisites. +4. Durability became a typed outcome rather than `Ok(()) == durable`. +5. Live fresh-agent ownership became a protected degradation case. +6. Browser invalidation and same-revision replacement became an integration + acceptance test. +7. `terminals.changed` became unconditional for successful REST terminal + creates; canonical metadata remains conditional on published identity. + +## File Map + +- Modify `crates/freshell-terminal/src/registry.rs`: atomic create publication + API, internal exit gate, deterministic concurrency tests. +- Modify `crates/freshell-ws/src/terminal.rs`: move its existing create-time + identity upsert into the atomic publication hook; keep durable work outside + the registry lock. +- Modify `crates/freshell-ws/src/pane_ledger.rs` and its tests: guarded terminal + binding result that preserves an actually-live fresh-agent owner. +- Modify `crates/freshell-freshagent/src/identity_sink.rs`: add the narrow + terminal publication/durability bridge types without changing existing + fresh-agent binding or pending contracts. +- Modify `crates/freshell-freshagent/src/lib.rs`: expose the injected bridge to + terminal-tab settlement, share `terminals_revision`, and expose an opaque + weak Codex-liveness handle where needed. +- Modify `crates/freshell-freshagent/src/terminal_tabs.rs`: strict explicit + Codex gate, atomic publish hook, awaited durable phase, exit retirement, + canonical metadata/invalidation broadcasts, and focused route tests. +- Modify `crates/freshell-server/src/identity_sink.rs`: real shared-registry and + pane-ledger adapter, combined route regression, disabled/live-owner/error + tests, and disk-reload assertion. +- Modify `crates/freshell-server/src/main.rs`: inject the shared identity, + shared terminal revision, pane ledger, and weak fresh-Codex liveness seam. +- Modify `test/unit/client/components/App.ws-bootstrap.test.tsx` or add one + narrowly named App integration test beside it: cached duplicate-to-one-row + invalidation regression using the real Redux store, thunk, reducer, handler, + and selector. +- Do not modify `src/store/selectors/sidebarSelectors.ts`. + +--- + +### Task 1: Pin the exact combined-route failure before production changes + +**Files:** + +- Test only: `crates/freshell-server/src/identity_sink.rs` + +**Purpose:** Reproduce the incident through the real REST create route and real +terminal-directory projection before any new API or wiring can accidentally +make the test green. + +- [ ] **Step 1: Build one real in-process route harness** + +In the existing server identity-sink test module, merge +`freshell_freshagent::router` and `terminals::router` around the same: + +- `TerminalRegistry` +- `TerminalIdentityRegistry` +- temporary, enabled `PaneLedger` +- `SettingsStore` +- broadcast bus +- shared terminal revision +- current `LedgerIdentitySink` + +Use a temporary recording CLI script that remains alive until cleanup. Do not +use the live server, the user's home, or the preserved evidence directory. + +- [ ] **Step 2: Seed the stale owner and POST the exact recovered Codex shape** + +Seed: + +```rust +ledger.record_binding(&BindingWrite { + provider: "codex", + session_id: "thread-restored", + terminal_id: "term-old", + mode: "codex", + cwd: Some(temp_dir), + create_request_id: Some("old-create"), + now_ms: 1, +})?; +``` + +Then POST: + +```json +{ + "mode": "codex", + "cwd": "", + "createRequestId": "create-restored", + "sessionRef": { + "provider": "codex", + "sessionId": "thread-restored" + } +} +``` + +Capture the returned terminal ID, GET `/api/terminals?priority=visible`, and +reduce the three authoritative observations to: + +```rust +( + directory_session_ref_for(&returned_terminal_id), + identity.session_ref_for(&returned_terminal_id), + ledger_live_terminal_id("codex", "thread-restored"), +) +``` + +The final expectation is `(Some(codex_ref), Some(codex_ref), +returned_terminal_id)`. + +- [ ] **Step 3: Run and preserve the genuine RED** + +Run: + +```bash +cargo test -p freshell-server identity_sink::tests::rest_codex_create_publishes_one_identity_across_directory_memory_and_disk -- --exact --nocapture +``` + +Before any production change, the assertion must reach runtime and report the +actual tuple exactly as: + +```text +(None, None, term-old) +``` + +If it fails to compile, fails in harness setup, or produces a different tuple, +fix only the test harness and rerun. Save the exact command and assertion +output in the task report. Do not commit a failing test by itself; keep it +uncommitted for the production tasks that make it green. + +--- + +### Task 2: Add atomic terminal publication and the per-create exit gate + +**Files:** + +- Modify and test: `crates/freshell-terminal/src/registry.rs` + +**Interfaces:** + +- Existing `TerminalRegistry::create` remains source-compatible and delegates + to a new atomic-publication variant. +- The new variant accepts a synchronous one-shot publication hook over the + just-inserted terminal's immutable publication fields. +- The hook cannot await, perform I/O, or re-enter the registry. + +- [ ] **Step 1: Write deterministic publication-order tests** + +Add tests that control two threads with barriers/channels rather than sleeps: + +1. `create_with_publication_hides_inventory_until_publication_completes` + blocks inside the publication hook, starts an inventory read on another + thread, and proves the read cannot return the terminal until the hook marks + identity published and the registry lock is released. +2. `create_with_publication_opens_exit_gate_after_identity_publication` + launches an immediately exiting fixture, records ordered events from the + publication and exit hooks, and requires + `row_inserted -> identity_published -> exit_retired`, never retirement + first. +3. `create_delegates_to_atomic_publication_without_behavior_change` pins the + legacy wrapper's ordinary create result, revision, and exit behavior. + +- [ ] **Step 2: Run the tests and preserve RED** + +Run each filter separately: + +```bash +cargo test -p freshell-terminal registry::tests::create_with_publication_hides_inventory_until_publication_completes -- --exact --nocapture +cargo test -p freshell-terminal registry::tests::create_with_publication_opens_exit_gate_after_identity_publication -- --exact --nocapture +cargo test -p freshell-terminal registry::tests::create_delegates_to_atomic_publication_without_behavior_change -- --exact --nocapture +``` + +Expected: compile failures for the missing API, followed by the intended +ordering failures as the minimal skeleton is introduced. + +- [ ] **Step 3: Implement the primitive** + +Introduce a narrow value such as: + +```rust +pub struct TerminalPublication<'a> { + pub terminal_id: &'a str, + pub mode: &'a str, + pub resume_session_id: Option<&'a str>, + pub create_request_id: Option<&'a str>, + pub cwd: Option<&'a str>, + pub created_at: i64, +} +``` + +and a method named for its guarantee, for example: + +```rust +pub fn create_with_publication( + &self, + /* existing create inputs */, + publish: F, +) -> io::Result<()> +where + F: FnOnce(TerminalPublication<'_>); +``` + +Implementation order: + +1. Create a per-create `Arc<(Mutex, Condvar)>`. +2. Wrap the caller's PTY exit hook so it waits for the gate to become + `Published` before running retirement/cleanup. +3. Spawn the PTY. +4. Lock `RegistryInner`. +5. Insert the row. +6. Invoke `publish` synchronously while still holding `RegistryInner`. +7. Increment the registry revision. +8. Mark the exit gate `Published` and notify waiters. +9. Drop `RegistryInner`. +10. Release create reservations, log `terminal.created`, and fire + `ActivityEvent::Created`. + +Because all inventory/probe/directory readers take `RegistryInner`, no public +row can escape between steps 5 and 6. Opening the exit gate before releasing +the registry lock ensures an early exit may block briefly but can never retire +before publication. + +Keep spawn-failure cleanup and reservation release unchanged. `create` must +delegate with a no-op publisher; do not duplicate the spawn implementation. + +- [ ] **Step 4: Reach GREEN and refactor** + +Run: + +```bash +cargo test -p freshell-terminal registry::tests::create_with_publication_hides_inventory_until_publication_completes -- --exact --nocapture +cargo test -p freshell-terminal registry::tests::create_with_publication_opens_exit_gate_after_identity_publication -- --exact --nocapture +cargo test -p freshell-terminal registry::tests::create_delegates_to_atomic_publication_without_behavior_change -- --exact --nocapture +cargo test -p freshell-terminal --all-targets +cargo fmt --check +cargo clippy -p freshell-terminal --all-targets -- -D warnings +``` + +Refactor only after GREEN. Re-run the same commands. + +- [ ] **Step 5: Commit the primitive** + +```bash +git add crates/freshell-terminal/src/registry.rs +git commit -m "fix: publish terminal state atomically" +``` + +--- + +### Task 3: Bridge and wire strict REST Codex identity and durability + +**Files:** + +- Modify and test: `crates/freshell-freshagent/src/identity_sink.rs` +- Modify and test: `crates/freshell-freshagent/src/codex.rs` +- Modify and test: `crates/freshell-freshagent/src/lib.rs` +- Modify and test: `crates/freshell-freshagent/src/terminal_tabs.rs` +- Modify and test: `crates/freshell-ws/src/pane_ledger.rs` +- Modify and test: `crates/freshell-ws/src/pane_ledger_tests.rs` +- Modify and test: `crates/freshell-server/src/identity_sink.rs` +- Modify: `crates/freshell-server/src/main.rs` + +**Interfaces:** + +Keep existing `record_pending`, `record_binding(FreshAgentBindingUpsert)`, and +`SinkWrite>` unchanged. Add a separate terminal-only contract: + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalPaneBinding { + pub provider: String, + pub session_id: String, + pub terminal_id: String, + pub mode: String, + pub cwd: Option, + pub create_request_id: Option, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalBindingDegradation { + LedgerUnavailable, + LiveFreshAgentOwner, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalBindingDurability { + Durable, + Degraded(TerminalBindingDegradation), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalBindingPreflight { + Publish, + Degraded(TerminalBindingDegradation), +} + +pub type TerminalBindingCheck = Pin< + Box> + Send + 'static> +>; + +pub type TerminalBindingWrite = Pin< + Box> + Send + 'static> +>; +``` + +Extend `PaneIdentitySink` with distinct phases: + +```rust +fn preflight_terminal_binding(&self, binding: &TerminalPaneBinding) + -> TerminalBindingCheck; +fn publish_terminal_identity(&self, binding: &TerminalPaneBinding); +fn persist_terminal_binding(&self, binding: TerminalPaneBinding) -> TerminalBindingWrite; +fn retire_terminal_identity(&self, terminal_id: &str); +``` + +`preflight_terminal_binding` is the async exact-liveness check and runs +immediately before spawn. `publish_terminal_identity` and +`retire_terminal_identity` are synchronous, memory-only calls. +`persist_terminal_binding` is the only new terminal filesystem path and is +called only after `TerminalBindingPreflight::Publish`. + +Add one pure scope gate used only for the new authoritative REST path: + +```rust +fn explicit_rest_codex_binding( + mode: &str, + accepted_session_ref: Option<&SessionLocator>, +) -> Option { + let session_ref = accepted_session_ref?; + (mode == "codex" + && session_ref.provider == "codex" + && !session_ref.session_id.is_empty()) + .then(|| session_ref.clone()) +} +``` + +Use the actual validated value without trimming, rewriting, or accepting a raw +fallback. Existing request validation remains the authority that produces +`accepted_session_ref`. + +- [ ] **Step 1: Write bridge and adapter RED tests** + +Add tests for: + +1. The fake sink records preflight, publication, durability request, and + retirement as separate events. +2. The real adapter synchronously upserts + `TerminalIdentityRegistry` before returning. +3. An enabled ledger seeded with stale terminal owner `term-old` returns + `Durable`, names `term-new`, and a newly constructed `PaneLedger` over the + same root reloads `term-new`. +4. A disabled ledger returns + `Degraded(LedgerUnavailable)` and is never reported as durable. +5. A target row with `paneKind:"fresh-agent"` plus an actually-live Codex + liveness probe returns preflight + `Degraded(LiveFreshAgentOwner)`, never publishes terminal identity, never + invokes persistence, and remains byte/field-equivalent to the fresh-agent + row. +6. The same stale fresh-agent row with a non-live/expired weak probe may be + replaced by the terminal binding. +7. A deterministic filesystem/test-writer error returns `Err`, not a typed + degradation. +8. Retirement after publication marks the shared identity retired without + deleting the durable binding. +9. The real REST route synchronously publishes an explicit accepted Codex + binding before the new terminal becomes visible. +10. Raw Codex resume, non-Codex `sessionRef`, and extension mode never call + the new terminal publication or durability methods. +11. Disabled ledger, live fresh owner, and actual write error each return HTTP + 200 and exactly one distinct `durability.degraded` frame. + +- [ ] **Step 2: Run bridge tests and preserve RED** + +Run filters separately: + +```bash +cargo test -p freshell-freshagent identity_sink::tests -- --nocapture +cargo test -p freshell-ws pane_ledger_tests::terminal_binding_preserves_live_fresh_agent_owner -- --exact --nocapture +cargo test -p freshell-server identity_sink::tests::terminal_binding_reports_disabled_ledger -- --exact --nocapture +cargo test -p freshell-server identity_sink::tests::terminal_binding_reloads_after_restart -- --exact --nocapture +cargo test -p freshell-server identity_sink::tests::terminal_binding_preserves_live_fresh_codex_owner -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_explicit_codex_session_ref_publishes_before_terminal_visibility -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_raw_codex_resume_does_not_enter_authoritative_binding_path -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_disabled_ledger_returns_200_and_one_ledger_unavailable_frame -- --exact --nocapture +``` + +Expected: missing types/methods and then the current silent-disabled/overwrite +behavior. + +- [ ] **Step 3: Add the weak fresh-Codex liveness seam** + +Expose an opaque handle from `FreshCodexState` that weakly references its +session map and can asynchronously answer whether a given Codex thread is +actually tracked live. The server adapter must retain only the weak handle, so +the durability bridge cannot extend the runtime's lifetime. + +Immediately before spawn for `codex:`: + +1. Inspect whether the existing row is a fresh-agent row. +2. If so, upgrade and query the weak Codex liveness handle. +3. If the owner is actually live, return + `Degraded(LiveFreshAgentOwner)`; the caller must make both the atomic + publisher and durable writer no-ops. +4. If the weak handle is expired or reports not live, return `Publish` and + allow stale-row replacement after spawn. + +The liveness await occurs before registry create and before any +`spawn_blocking` disk I/O. Do not hold a Tokio mutex across blocking I/O. + +This protects an owner already live at the check. It does not make a terminal +create and a concurrently starting fresh-agent create one atomic admission +transaction; retain that residual risk in the final report. + +- [ ] **Step 4: Implement the real adapter** + +`LedgerIdentitySink` receives: + +- the shared `PaneLedger` +- the shared `TerminalIdentityRegistry` +- the weak fresh-Codex liveness handle + +`preflight_terminal_binding` checks only the exact existing fresh-agent owner +and live runtime described above. An expired/not-live owner returns +`Publish`. A live owner returns +`Degraded(LiveFreshAgentOwner)`. +`publish_terminal_identity` calls `TerminalIdentityRegistry::upsert` only. +`retire_terminal_identity` calls `TerminalIdentityRegistry::retire` only. +`persist_terminal_binding`: + +1. Returns `Degraded(LedgerUnavailable)` immediately when + `!ledger.is_enabled()`. +2. Uses `spawn_blocking` to write a terminal `BindingWrite`. +3. Maps a successful enabled write to `Durable`. +4. Propagates `JoinError` and filesystem failures as `io::Error`. + +The route must never call `publish_terminal_identity` or +`persist_terminal_binding` after a degraded preflight. + +Do not record or delete pending markers in any of these methods. + +Wire the shared identity and weak liveness handle in `main.rs`; retain the same +adapter instance for existing fresh-agent sink consumers. Add a loud boot +assertion that the REST terminal identity bridge was injected. + +- [ ] **Step 5: Wire the strict REST publication and durability phases** + +In `settle_gated_create`: + +1. Compute `explicit_rest_codex_binding` once from `mode` and + `accepted_session_ref`. +2. Build one `TerminalPaneBinding` from that exact locator, terminal ID, cwd, + mode, create request ID, and event timestamp. +3. Immediately before spawn, await `preflight_terminal_binding`. An + ineligible request behaves as today. `Publish` authorizes the next two + phases. `Degraded(LiveFreshAgentOwner)` records the one degradation outcome + and authorizes neither phase. +4. Pass a synchronous publisher to + `TerminalRegistry::create_with_publication`. The publisher calls + `publish_terminal_identity` only for a `Publish` binding. For every + ineligible or preflight-degraded shape it is a no-op. +5. Capture the bridge in the existing exit hook only for a published binding; + after `finish_pty_exit`, call `retire_terminal_identity`. +6. Complete existing adoption, meta, locator, D8 winner, pane, and tab + settlement unchanged. +7. Await `persist_terminal_binding` outside all registry locks and only after + the create has won settlement, and only for a published binding. + +Never call `record_pending` or reuse the pane-payload promotion result. + +Map the durability result once: + +- `Durable`: no warning frame. +- `Degraded(LedgerUnavailable)`: structured warning plus exactly one + `durability.degraded{reason:"pane_ledger_unavailable"}`. +- preflight `Degraded(LiveFreshAgentOwner)`: structured invariant warning plus + exactly one + `durability.degraded{reason:"live_fresh_agent_owner"}`. +- `Err`: structured error plus exactly one + `durability.degraded{reason:"ledger_write_failed"}`. + +All four branches continue with the successfully spawned terminal and HTTP +200. The route is the sole user-visible failure surface; the adapter must not +broadcast a second warning. + +- [ ] **Step 6: Turn the Task 1 regression GREEN and verify the bridge** + +Run: + +```bash +cargo test -p freshell-freshagent identity_sink::tests -- --nocapture +cargo test -p freshell-freshagent codex::tests::weak_liveness_handle_tracks_runtime_without_owning_it -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_explicit_codex_session_ref_publishes_before_terminal_visibility -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_explicit_codex_early_exit_retires_after_publication -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_raw_codex_resume_does_not_enter_authoritative_binding_path -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_disabled_ledger_returns_200_and_one_ledger_unavailable_frame -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_live_fresh_owner_returns_200_and_one_live_owner_frame -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_ledger_error_returns_200_and_one_write_failed_frame -- --exact --nocapture +cargo test -p freshell-ws pane_ledger_tests::terminal_binding_preserves_live_fresh_agent_owner -- --exact --nocapture +cargo test -p freshell-ws pane_ledger_tests::terminal_binding_replaces_stale_fresh_agent_owner -- --exact --nocapture +cargo test -p freshell-server identity_sink::tests -- --nocapture +cargo test -p freshell-server identity_sink::tests::rest_codex_create_publishes_one_identity_across_directory_memory_and_disk -- --exact --nocapture +cargo fmt --check +cargo clippy -p freshell-freshagent -p freshell-ws -p freshell-server --all-targets -- -D warnings +``` + +Require the Task 1 tuple to be fully green: + +- `/api/terminals` returns the new terminal with + `sessionRef:{provider:"codex",sessionId:"thread-restored"}`. +- `TerminalIdentityRegistry::session_ref_for(new_id)` returns the same locator. +- The live ledger object names `new_id`, not `term-old`. +- A newly constructed `PaneLedger` over the same temporary root reloads + `new_id`. +- The HTTP response is 200. + +Refactor only after GREEN, then rerun all commands in this step. + +- [ ] **Step 7: Commit the strict bridge and REST core** + +```bash +git add crates/freshell-freshagent/src/identity_sink.rs \ + crates/freshell-freshagent/src/codex.rs \ + crates/freshell-freshagent/src/lib.rs \ + crates/freshell-freshagent/src/terminal_tabs.rs \ + crates/freshell-ws/src/pane_ledger.rs \ + crates/freshell-ws/src/pane_ledger_tests.rs \ + crates/freshell-server/src/identity_sink.rs \ + crates/freshell-server/src/main.rs +git commit -m "fix: publish REST Codex terminal identity" +``` + +--- + +### Task 4: Broadcast REST convergence and migrate WebSocket publication + +**Files:** + +- Modify and test: `crates/freshell-freshagent/src/lib.rs` +- Modify and test: `crates/freshell-freshagent/src/terminal_tabs.rs` +- Modify and test: `crates/freshell-ws/src/terminal.rs` +- Modify and test: `crates/freshell-server/src/identity_sink.rs` +- Modify: `crates/freshell-server/src/main.rs` + +- [ ] **Step 1: Write event-order and WebSocket atomic-publication RED tests** + +Using the fake bridge and recording CLI, add: + +1. `rest_explicit_codex_create_broadcasts_ui_then_changed_then_meta_before_response` + pins normal-delivery parity with WebSocket create. +2. `rest_explicit_codex_deferred_create_broadcasts_changed_then_meta_before_response_without_broadcasting_ui_command` + pins deferred restore delivery. +3. `rest_degraded_binding_still_broadcasts_one_invalidation_sequence` + proves degradation does not skip or duplicate convergence frames. +4. `rest_ineligible_create_broadcasts_changed_without_canonical_meta` proves + raw resume and non-Codex creates gain only terminal-directory invalidation, + not new identity semantics. +5. A WebSocket create-publication regression proves inventory/probe cannot + observe an identity-bearing created row without the matching identity. +6. A WebSocket instant-exit regression proves retirement follows publication. +7. Existing WS durable binding and pending-marker tests continue to pin their + current behavior. + +- [ ] **Step 2: Run focused RED commands separately** + +```bash +cargo test -p freshell-freshagent terminal_tabs::tests::rest_explicit_codex_create_broadcasts_ui_then_changed_then_meta_before_response -- --exact --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests::rest_explicit_codex_deferred_create_broadcasts_changed_then_meta_before_response_without_broadcasting_ui_command -- --exact --nocapture +cargo test -p freshell-ws terminal::tests::create_publication_is_atomic_with_identity -- --exact --nocapture +cargo test -p freshell-ws terminal::tests::instant_exit_retires_after_create_publication -- --exact --nocapture +``` + +Expected: REST event-order failures and missing WS use of the new publication +API. If the WS module path differs, discover it with +`cargo test -p freshell-ws -- --list`; continue using one valid filter per +command. + +- [ ] **Step 3: Broadcast canonical metadata and invalidation before delivery** + +Inject the same `Arc` used by `WsState` and `TerminalsState` into +`FreshAgentState` via `with_shared_terminals_revision` or its existing +post-construction setter pattern. + +For every successful REST terminal create, regardless of mode, durability +outcome, and `broadcast`/deferred UI delivery: + +1. For ordinary delivery, broadcast `ui.command{tab.create}`. +2. Increment the shared terminal revision and broadcast + `terminals.changed{revision}`. +3. Only when terminal identity was atomically published, build + `TerminalMetaRecord` from the exact `TerminalPaneBinding` and broadcast + `terminal.meta.updated{upsert:[record],remove:[]}`. +4. For deferred delivery, put `uiCommand` in the response and do not broadcast + it. +5. Return the HTTP response. + +This mirrors WebSocket create ordering for ordinary delivery and lets the +invalidation frames coalesce into one browser refresh. `terminals.changed` is +the intentional cross-mode parity fix; canonical metadata remains strictly +limited to the successfully published explicit Codex binding. A live-owner +preflight degradation therefore emits `terminals.changed` but no terminal +metadata claim. + +- [ ] **Step 4: Move WebSocket's existing in-memory upsert into atomic publication** + +The WebSocket path already decides which create has a `create_meta_record`. +Keep that decision and all durable/pending rules unchanged, but: + +1. Build the existing meta record before calling the registry. +2. Pass its identity upsert as the synchronous + `create_with_publication` hook. +3. Remove the post-create duplicate `state.identity.upsert`. +4. Keep the existing pane-ledger binding/pending `spawn_blocking` work after + create returns and outside the registry lock. +5. Keep the existing `terminal.created`, `terminal.meta.updated`, and + `terminals.changed` wire semantics. + +Add a WS regression proving an inventory/probe cannot observe an +identity-bearing created row without the matching identity and that an +instant exit retires only after publication. + +- [ ] **Step 5: Extend the combined regression through the delivery boundary** + +Rerun: + +```bash +cargo test -p freshell-server identity_sink::tests::rest_codex_create_publishes_one_identity_across_directory_memory_and_disk -- --exact --nocapture +``` + +Require all of: + +- `/api/terminals` returns the new terminal with + `sessionRef:{provider:"codex",sessionId:"thread-restored"}`. +- `TerminalIdentityRegistry::session_ref_for(new_id)` returns the same locator. +- The live ledger object names `new_id`, not `term-old`. +- A newly constructed `PaneLedger` over the same temporary root reloads + `new_id`. +- The HTTP response is 200. +- The broadcast sequence contains canonical metadata and a shared positive + terminal revision before the UI create delivery. + +- [ ] **Step 6: Run focused GREEN and crate tests** + +Run each filter group independently: + +```bash +cargo test -p freshell-freshagent identity_sink::tests -- --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests -- --nocapture +cargo test -p freshell-ws terminal::tests::create_publication -- --nocapture +cargo test -p freshell-server identity_sink::tests -- --nocapture +cargo test -p freshell-freshagent --all-targets +cargo test -p freshell-ws --all-targets +cargo test -p freshell-server --all-targets +cargo fmt --check +cargo clippy -p freshell-terminal -p freshell-freshagent -p freshell-ws -p freshell-server --all-targets -- -D warnings +``` + +If the WS test module uses a different exact path, discover it with +`cargo test -p freshell-ws -- --list` and run one valid filter at a time. Do not +pass two Cargo test filters in one command. + +- [ ] **Step 7: Commit convergence and WebSocket migration** + +```bash +git add crates/freshell-freshagent/src/lib.rs \ + crates/freshell-freshagent/src/terminal_tabs.rs \ + crates/freshell-ws/src/terminal.rs \ + crates/freshell-server/src/identity_sink.rs \ + crates/freshell-server/src/main.rs +git commit -m "fix: broadcast terminal identity convergence" +``` + +--- + +### Task 5: Prove a cached browser row converges without selector changes + +**Files:** + +- Test only: `test/unit/client/components/App.ws-bootstrap.test.tsx`, or a new + narrowly scoped App integration test beside it +- Do not modify `src/store/selectors/sidebarSelectors.ts` + +**Purpose:** Protect the exact user-visible failure after the server is fixed: +the browser may already have cached the new terminal as unidentified before it +receives the create's invalidations. + +- [ ] **Step 1: Write the real Redux/App integration regression** + +Use the existing App WebSocket/API harness with the real: + +- configured Redux store +- `createTerminalInvalidationHandler` +- `fetchTerminalDirectoryWindow` +- terminal-directory reducer +- `makeSelectSortedSessionItems` +- sidebar/App rendering path + +Seed: + +1. An open pane/tab named `gf ui` with + `sessionRef = codex:thread-restored` and `terminalId = term-new`. +2. A cached sidebar terminal-directory window at revision `1700` whose same + running `term-new` row has no `sessionRef`. +3. Session data that makes the selector initially expose both the real + `codex:thread-restored` row and the synthetic + `codex:terminal:term-new` fallback. + +Then deliver back-to-back: + +```json +{ + "type": "terminals.changed", + "revision": 12 +} +``` + +and: + +```json +{ + "type": "terminal.meta.updated", + "upsert": [{ + "terminalId": "term-new", + "provider": "codex", + "sessionId": "thread-restored", + "updatedAt": 1700 + }], + "remove": [] +} +``` + +Mock the single ensuing +`GET /api/terminals?priority=visible&revision=1700` to return current +`term-new` with the canonical `sessionRef` but the same derived directory +revision `1700`. + +- [ ] **Step 2: Preserve RED if the integration test exposes a real delivery gap** + +Run: + +```bash +npm run test:vitest -- run test/unit/client/components/App.ws-bootstrap.test.tsx +``` + +The expected current primitives already support convergence. If the test is +RED, determine whether the App harness omitted the real invalidation path +before changing production code. Production selector changes are forbidden. +Only a proven App/invalidation delivery defect may be fixed, and such a defect +must be reported to the controller before expanding the task. + +- [ ] **Step 3: Assert coalescing and convergence** + +After advancing the real debounce boundary: + +- exactly one terminal-directory request was issued for the two invalidations +- the request carried cached revision `1700` +- a same-revision response was accepted +- the Redux directory window replaced the unidentified cached row +- `makeSelectSortedSessionItems` contains exactly one session key for the + terminal, `codex:thread-restored` +- the rendered sidebar contains one `gf ui`, not two +- no selector fallback logic was modified + +- [ ] **Step 4: Run client-focused verification** + +```bash +npm run test:vitest -- run test/unit/client/components/App.ws-bootstrap.test.tsx +npm run test:vitest -- run test/unit/client/lib/terminal-invalidation-handler.test.ts +npm run test:vitest -- run test/unit/client/store/selectors/sidebarSelectors.test.ts +``` + +- [ ] **Step 5: Commit the regression** + +```bash +git add test/unit/client/components/App.ws-bootstrap.test.tsx +git commit -m "test: cover REST Codex sidebar convergence" +``` + +If a new test file was chosen, add that exact file instead. + +--- + +### Task 6: Whole-branch verification and handoff + +**Files:** + +- Verify only. Change implementation files solely for failures caused by this + branch, using a focused fix commit with its covering test. + +- [ ] **Step 1: Run focused regressions with valid single filters** + +```bash +cargo test -p freshell-terminal registry::tests::create_with_publication -- --nocapture +cargo test -p freshell-freshagent identity_sink::tests -- --nocapture +cargo test -p freshell-freshagent terminal_tabs::tests -- --nocapture +cargo test -p freshell-server identity_sink::tests -- --nocapture +npm run test:vitest -- run test/unit/client/components/App.ws-bootstrap.test.tsx +npm run test:vitest -- run test/unit/client/lib/terminal-invalidation-handler.test.ts +npm run test:vitest -- run test/unit/client/store/selectors/sidebarSelectors.test.ts +``` + +Do not combine `identity_sink::tests` and `terminal_tabs::tests` as two Cargo +filters; Cargo accepts one test-name filter. + +- [ ] **Step 2: Run Rust workspace quality gates** + +```bash +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --all-targets +``` + +- [ ] **Step 3: Run repository checks through the shared coordinator** + +```bash +npm run test:status +FRESHELL_TEST_SUMMARY='verify REST Codex terminal identity publication branch' npm run check +npm run lint +``` + +Wait for the coordinator if another holder owns it. Do not kill or bypass a +foreign holder. + +- [ ] **Step 4: Audit scope and residual risk** + +```bash +git status --short +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git log --oneline --decorate origin/main..HEAD +``` + +Require: + +- no uncommitted changes +- no selector modification +- no pending/provider/extension behavior changes +- no live-server or production artifacts +- focused commits only + +Record the deliberate residual risk: an already-live fresh Codex owner is +protected, but a terminal create and fresh-agent create that begin +concurrently across their separate admission domains are not made one atomic +transaction by this incident fix. + +- [ ] **Step 5: Run the usual final reviews** + +1. Request a broad code review over `origin/main...HEAD` and resolve all + actionable findings. +2. Run Fresh Eyes on the complete delta up to five times, fixing and committing + findings between rounds. +3. Stop only on a serious data-loss/confusion risk. If round five does not + pass, report that loudly and do not imply readiness. +4. After the final fix, rerun the covering focused tests and every affected + broad gate. + +- [ ] **Step 6: Preserve the branch and worktree** + +Do not open a PR, merge, deploy, restart port 3002, or remove the worktree. +Report: + +- branch and worktree +- base and head SHAs +- focused commits +- Task 1 RED tuple +- load-bearing corrections +- plan Fresh Eyes rounds +- task review results +- final-delta Fresh Eyes rounds +- exact verification commands and outcomes +- the concurrent cross-kind residual risk +- readiness for explicit PR approval + +## Acceptance Summary + +1. Before production edits, the combined route test proves the incident as + `(None, None, term-old)`. +2. A validated explicit REST Codex `sessionRef` is published atomically with + its terminal registry row. +3. An immediate child exit cannot retire before publication. +4. `/api/terminals`, the shared identity registry, and a disk-reloaded + pane ledger all name the returned terminal and Codex session after a + durable result. +5. Disabled ledger, live fresh-agent owner, and real write error each preserve + HTTP 200 and emit exactly one distinct degradation frame. +6. A live fresh Codex ledger owner is not overwritten. +7. Raw resume IDs and all non-Codex/provider/extension paths retain their + existing authoritative behavior. +8. Every successful REST terminal create publishes a shared-revision + invalidation; published explicit Codex identity also emits canonical + metadata. Both ordinary and deferred flows emit their required + invalidations before the HTTP response. +9. The WebSocket path uses the same atomic in-memory publication primitive + while retaining its existing durable/pending rules. +10. The real App/Redux flow replaces a cached unidentified same-revision row, + coalesces the two invalidations into one fetch, and renders one `gf ui` + without a selector patch. +11. All changes are committed locally, fully verified, reviewed, and left + undeployed with no PR. From 3ac597475b3d70f934418fdc7165f1d162d6fa66 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:52:00 -0700 Subject: [PATCH 02/15] docs: add implementation plan for rest-terminal-session-identity --- ...26-07-29-rest-terminal-session-identity.md | 1432 +++++++++++++++++ 1 file changed, 1432 insertions(+) create mode 100644 docs/plans/2026-07-29-rest-terminal-session-identity.md diff --git a/docs/plans/2026-07-29-rest-terminal-session-identity.md b/docs/plans/2026-07-29-rest-terminal-session-identity.md new file mode 100644 index 000000000..8c0d6f7c4 --- /dev/null +++ b/docs/plans/2026-07-29-rest-terminal-session-identity.md @@ -0,0 +1,1432 @@ +# REST Claude Pane Session Identity Parity Implementation Plan (kata hbsa) + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Claude panes created via the REST agent API (`POST /api/tabs`, `POST /api/panes/:id/split`, `POST /api/panes/:id/respawn`) acquire full session identity at create time — preallocated `--session-id` in argv, pre-spawn pane-ledger binding (PIN 2), `TerminalIdentityRegistry` row, and a real `sessionRef` on every reporting surface — exactly like the WS `terminal.create` fresh-claude path, so REST panes are resumable and visible to the A13 live-owner guard. + +**Architecture:** Three moves. (1) Extract the WS fresh-claude preallocation *predicate* into `freshell-platform` (both crates already depend on it) and mint the UUID on the REST path, flipping `LaunchIntent` to `Start` so `claude --session-id ` lands in argv — this alone populates the registry row, `paneContent.sessionRef`, and `GET /api/terminals` rung 0. (2) Bridge the crate boundary for the two write-side identity homes (`TerminalIdentityRegistry` + `PaneLedger`, both `freshell-ws`-owned and unreachable from `freshell-freshagent`) with a new `PaneIdentityBinder` trait defined in `freshell-terminal` (same seam pattern as the existing read-only `SessionIdentityLookup`), implemented in `freshell-ws`, injected in `freshell-server::main`. (3) Regression tests at the merged REST+WS server level proving resume identity, A13 refusal, and SessionStart signal consumption, plus pinning tests for the codex/opencode REST lanes. + +**Tech Stack:** Rust (toolchain pinned 1.96.0), axum, tokio, `async-trait`, `uuid`; existing test harnesses in `crates/freshell-ws/tests/common/mod.rs` and `crates/freshell-freshagent/src/terminal_tabs.rs mod tests`. + +**Baseline:** branch `fix/rest-terminal-session-identity` in worktree `/home/dan/code/freshell/.worktrees/rest-terminal-session-identity`, branched from `origin/main` @ `4c04dc9c`. All file:line references below are against that commit. + +## Global Constraints + +- **NEVER touch the production Rust server on port 3002.** It sweeps `~/.freshell/session-signals/` every ~1s and holds the pane-ledger flock. Tests MUST use isolated temp dirs: construct `ClaudeSignalWatcher::new()` (never `default_root()`), `PaneLedger::new(Some())` or `PaneLedger::disabled()` (never `new_locked`), and never `std::env::set_var("HOME", ..)`. +- Red-green-refactor TDD; run the failing test before implementing. +- `cargo fmt --all --check` and `cargo clippy --workspace --all-targets -- -D warnings` must pass (toolchain 1.96.0). +- Contract freeze diff-clean: **no new WS frames, no WS contract changes.** Verify with `npm run test:port && npm run contract:generate && git diff --exit-code -- port/contract` and `cargo test -p freshell-protocol --locked`. +- Coordinated JS runs only: `npm ci` in the worktree first (known environmental failure otherwise), then `FRESHELL_TEST_SUMMARY="rest terminal session identity regression" npm run check`. +- Do NOT regress: A13 live-owner guard, D7/D8 leases, one-writer invariant, PIN 2 pre-spawn binding scoping (eaa25b7d: the pre-spawn *write* gate and the spawn-failure *delete* gate must be the SAME predicate, fresh-claude-preallocs only), act-then-delete drain disposition (#578), deterministic signal ordering (#575), hello/HelloTracker, salvage-hardening (#573), codex self-healing (#574), opencode existence-fallback (#579). +- The WS fresh-claude path (`crates/freshell-ws/src/terminal.rs:1630-1661`, `:2194-2236`, `:2453-2538`, `:2597-2609`) is the reference implementation — reuse its code where the crate graph allows (the shared predicate), mirror it exactly where it does not (the binder). No forced refactors beyond that. +- Fresh-claude preallocation must NOT claim a D8 sessionRef lease (`create_session_locator` doctrine, `terminal.rs:1163-1178`) — the minted id is freshly minted, carries no concurrent-duplicate shape, and must not route through the lease path. On REST this is automatic: the D7/D8 block at `terminal_tabs.rs:961-1038` keys off `accepted_session_ref`, which is `None` for a minted id. Keep it that way. +- Ledger write failures never block a create — log and proceed (best-effort durability, mirrors `surface_write_failure` policy). +- Kata: tracked as kata issue hbsa. Do NOT close it from within this workflow; the controller closes it after the branch lands. +- Do NOT create a PR — stop after pushing the branch. +- README.md is the only end-user markdown doc; this plan under `docs/plans/` is a working/agent doc. Create no other markdown files. +- There is a pre-existing **untracked** file `docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md` in the worktree from a different (codex-scoped) workflow. Leave it untracked; never `git add -A` — stage files explicitly in every commit. + +## Node Parity Decision (Required Outcome 5 — resolved, no Node code changes) + +The repo's parity conventions require **Rust-only** work here: + +- Root `AGENTS.md:68`: the Node server "still exists but is not what the user runs day-to-day." +- `port/AGENTS.md`: "Fix bugs; do not replicate them" — fixes land in the port (Rust), with the original's behavior logged, not mirrored. +- Direct precedent: the PIN 2 pre-spawn binding work (`c12e7d71`/`eaa25b7d`) and the REST spawn gate ("A4 parity with WS", `docs/plans/2026-07-27-rest-spawn-gate.md`) — the closest analogues to this fix — landed Rust-only. `docs/plans/2026-07-29-rebind-salvage-hardening.md:49-50` says "OUT OF SCOPE: the Node/TypeScript server (exists, not production — add NO Node server functionality)"; `docs/plans/2026-07-29-opencode-existence-fallback.md:18` says "Rust server only. No Node server work." +- Node's ledger doesn't exist (the pane-identity ledger is Rust-only, P1.8), so most of this fix has no Node counterpart to mirror anyway. + +What Requirement 5 therefore reduces to in this plan: (a) zero `server/` changes; (b) the frozen WS contract stays diff-clean (verified in Task 8); (c) the stale code comment in `terminal_tabs.rs:1327-1335` that cites `router.ts` as justification for the legacy split is rewritten when the split is fixed (Task 2), so the Node lineage is no longer cited as load-bearing. + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `crates/freshell-platform/src/cli_launch.rs` | Modify | New shared pure predicate `should_preallocate_fresh_claude(..)` + unit tests (truth table). Single source of the "fresh claude" policy for both doors. | +| `crates/freshell-ws/src/terminal.rs` | Modify | `handle_create` adopts the shared predicate (behavior-preserving swap of `terminal.rs:1630-1637`). | +| `crates/freshell-terminal/src/registry.rs` | Modify | New `PaneIdentityBinder` trait next to `SessionIdentityLookup` (`registry.rs:638-641`) — the write-side seam. | +| `crates/freshell-terminal/Cargo.toml` | Modify | Add `async-trait` dependency (workspace version). | +| `crates/freshell-ws/src/pane_identity_binder.rs` | Create | `LedgerPaneIdentityBinder` — the production impl over `TerminalIdentityRegistry` + `Arc`, mirroring `terminal.rs:2211-2236`, `:2281-2292`, `:2487-2538`. In-module unit tests with tempdir ledger. | +| `crates/freshell-ws/src/lib.rs` | Modify | `pub mod pane_identity_binder;` | +| `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify | UUID mint after `derive_resume_identity` (`:767`); `claude_fresh_prealloc` threading through `GatedSettleInputs`; `LaunchIntent` conditional (`:1335`); binder call sites (pre-spawn, failure-delete, post-spawn); `tab_create_missing_session_identity` condition update (`:1792-1805`); unit tests in `mod tests`. | +| `crates/freshell-freshagent/src/lib.rs` | Modify | `FreshAgentState.pane_identity: Option>` + `with_pane_identity_binder(..)` builder (mirror `with_session_identity`, `lib.rs:455-461`). | +| `crates/freshell-server/src/main.rs` | Modify | Wire `LedgerPaneIdentityBinder` into the freshagent state (next to the `with_session_identity` wiring at `main.rs:286`). | +| `crates/freshell-ws/tests/rest_claude_identity.rs` | Create | End-to-end merged REST+WS server tests: resume identity survives signal destruction (4a), A13 refusal of WS resume of a REST-live session (4b), SessionStart signal consumed Acted (4c), REST resume-direction ledger/identity writes. | +| `crates/freshell-ws/tests/rest_locator_identity.rs` | Create | Requirement 3 pinning tests: REST-created codex and opencode panes end with identity row + Bound ledger row (and a pending marker at create). | +| `crates/freshell-ws/tests/pane_ledger_restore.rs` | Modify (if red) | Reconcile the REST-resume "only footprint is a live registry row" premise (`:237-302`) with the new behavior. | + +Dependency direction (why the trait lives in `freshell-terminal`): `freshell-freshagent` cannot depend on `freshell-ws` (circular — stated at `terminal_tabs.rs:1401-1409`). Both depend on `freshell-terminal` and `freshell-platform`. `freshell-server` depends on everything and does the wiring. This is exactly the `SessionIdentityLookup` precedent. + +--- + +### Task 1: Shared fresh-claude preallocation predicate + +**Files:** +- Modify: `crates/freshell-platform/src/cli_launch.rs` (new pub fn + `mod tests` additions) +- Modify: `crates/freshell-platform/src/lib.rs` (re-export, matching how `CliCommandSpec` is re-exported) +- Modify: `crates/freshell-ws/src/terminal.rs:1630-1637` (adopt the helper) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `freshell_platform::should_preallocate_fresh_claude(mode: &str, restore: Option, has_session_ref: bool, resume_session_id: Option<&str>) -> bool` — used by Task 2 (REST) and by the WS handler from this task onward. + +- [ ] **Step 1: Write the failing unit tests** + +In `crates/freshell-platform/src/cli_launch.rs`, inside the existing `#[cfg(test)] mod tests` (or a new one if the file keeps tests elsewhere — follow the file's existing convention): + +```rust +#[test] +fn fresh_claude_preallocation_predicate_truth_table() { + use super::should_preallocate_fresh_claude as pred; + // The three-part freshness predicate from the WS reference + // (crates/freshell-ws/src/terminal.rs:1630-1637): mode == "claude" + // AND restore != Some(true) AND no sessionRef AND no non-empty + // resumeSessionId. + assert!(pred("claude", None, false, None)); + assert!(pred("claude", Some(false), false, None)); + // Empty resume id is treated as absent (matches the WS + // `.filter(|s| !s.is_empty()).is_none()` shape). + assert!(pred("claude", None, false, Some(""))); + // Any disqualifier kills the mint: + assert!(!pred("claude", Some(true), false, None)); // restore create + assert!(!pred("claude", None, true, None)); // wire sessionRef present + assert!(!pred("claude", None, false, Some("29a53649-0000-4000-8000-000000000000"))); // resume + // Only claude mints with Start intent; other providers never do here: + assert!(!pred("shell", None, false, None)); + assert!(!pred("codex", None, false, None)); + assert!(!pred("amplifier", None, false, None)); + assert!(!pred("opencode", None, false, None)); + assert!(!pred("gemini", None, false, None)); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p freshell-platform fresh_claude_preallocation_predicate_truth_table -- --exact` +Expected: FAIL to compile — `should_preallocate_fresh_claude` not defined. + +- [ ] **Step 3: Implement the predicate** + +In `crates/freshell-platform/src/cli_launch.rs` (top-level, near `resolve_coding_cli_command`): + +```rust +/// LIVE-PATH LAW (specs/coding-cli.md §2.1(3)): fresh claude ALWAYS gets a +/// server-preallocated `--session-id`. This is the single shared "is this +/// create a fresh claude that must mint its own session id?" predicate, +/// used by BOTH doors — the WS `terminal.create` handler +/// (`freshell-ws/src/terminal.rs`) and the REST spawn pipeline +/// (`freshell-freshagent/src/terminal_tabs.rs`) — so the two cannot drift +/// (kata hbsa: the REST door skipped preallocation entirely, leaving +/// un-resumable panes invisible to the A13 live-owner guard). +/// +/// The caller that gets `true` mints `Uuid::new_v4()`, sets +/// `LaunchIntent::Start` (claude's manifest has `create_session_args`), +/// and marks the create as a fresh prealloc for PIN 2 gating (eaa25b7d). +pub fn should_preallocate_fresh_claude( + mode: &str, + restore: Option, + has_session_ref: bool, + resume_session_id: Option<&str>, +) -> bool { + mode == "claude" + && restore != Some(true) + && !has_session_ref + && resume_session_id.filter(|s| !s.is_empty()).is_none() +} +``` + +Re-export from `crates/freshell-platform/src/lib.rs` the same way `CliCommandSpec` is re-exported (add `should_preallocate_fresh_claude` to the existing `pub use` for the `cli_launch` module). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p freshell-platform fresh_claude_preallocation_predicate_truth_table -- --exact` +Expected: PASS + +- [ ] **Step 5: Adopt the helper in the WS handler (behavior-preserving)** + +In `crates/freshell-ws/src/terminal.rs:1630-1637`, replace the inline predicate: + +```rust + let should_preallocate_fresh_claude = mode == "claude" + && create.restore != Some(true) + && create.session_ref.is_none() + && create + .resume_session_id + .as_deref() + .filter(|s| !s.is_empty()) + .is_none(); +``` + +with: + +```rust + // Shared with the REST spawn pipeline (kata hbsa) — one predicate, + // two doors: freshell_platform::should_preallocate_fresh_claude. + let should_preallocate_fresh_claude = freshell_platform::should_preallocate_fresh_claude( + &mode, + create.restore, + create.session_ref.is_some(), + create.resume_session_id.as_deref(), + ); +``` + +Do not touch anything else in the `if mode != "shell"` block — the amplifier sibling (`should_preallocate_fresh_amplifier`) and the `claude_fresh_prealloc = true` assignment at `:1660` stay exactly as they are. + +- [ ] **Step 6: Run the WS regression suite to prove no drift** + +Run: +```bash +cargo test -p freshell-ws --test claude_session_rebind +cargo test -p freshell-ws --test live_session_ref_guard +cargo test -p freshell-ws --test pane_ledger_triggers +``` +Expected: all PASS (these pin the fresh-claude preallocation, A13 refusal, and eaa25b7d scoping respectively). + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-platform/src/cli_launch.rs crates/freshell-platform/src/lib.rs crates/freshell-ws/src/terminal.rs +git commit -m "refactor(platform): shared fresh-claude preallocation predicate for both create doors" +``` + +--- + +### Task 2: REST mint — preallocated `--session-id`, `LaunchIntent::Start`, sessionRef surfaces + +**Files:** +- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` (`spawn_terminal_pane` ~`:767`, `GatedSettleInputs` `:1130`, `settle_gated_create` `:1322-1339`, warn site `:1792-1805`, `mod tests`) + +**Interfaces:** +- Consumes: `freshell_platform::should_preallocate_fresh_claude` (Task 1). +- Produces: `GatedSettleInputs.claude_fresh_prealloc: bool` — Task 5's binder call sites gate on this exact field (eaa25b7d symmetry). Fresh REST claude creates now carry `resume_session_id = Some()` through the whole pipeline: `registry.create(..)` (`:1481-1493`), `registry.set_meta(.., resume_session_id)` (`:1589-1595`), `paneContent.sessionRef` promotion (`:1683-1692`), and `GET /api/terminals` rung 0 (`crates/freshell-server/src/terminals.rs:686-698` reads `entry.resume_session_id` — no change needed there). + +- [ ] **Step 1: Write the failing test — fresh REST claude tab preallocates identity** + +In `crates/freshell-freshagent/src/terminal_tabs.rs` `mod tests` (starts `:2190`). Mirror the harness of the existing spawning test `create_opencode_tab_fresh_spawns_with_hostname_port_args_and_arms_locator` (`:3321`) for how the test injects a fake CLI spec and captures argv — reuse its helper functions verbatim (same state constructor, same script-writing helper, same argv-capture mechanism), swapping the spec for a claude-named one. The claude spec MUST include `create_session_args: Some(vec!["--session-id".into(), "{{sessionId}}".into()])` — `LaunchIntent::Start` hard-errors without it (`cli_launch.rs:496-510`, `StartIntentUnsupported`). + +```rust +#[tokio::test] +async fn create_fresh_claude_tab_preallocates_session_identity() { + // kata hbsa P1: REST parity with the WS fresh-claude special case. + // A fresh POST /api/tabs {mode:"claude"} must mint a --session-id, + // carry it in the registry row, and expose it as paneContent.sessionRef. + let (state, registry, argv_capture_path) = state_with_claude_capture_spec(); // build per :3321's harness + let (status, body) = post( + app(state), + "/api/tabs", + serde_json::json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + }), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "create failed: {body}"); + + // 1. sessionRef surfaced on the create response (paneContent promotion). + let session_ref = body["data"]["paneContent"]["sessionRef"].clone(); + assert_eq!(session_ref["provider"], serde_json::json!("claude"), "sessionRef: {body}"); + let sid = session_ref["sessionId"].as_str().expect("sessionId string").to_string(); + uuid::Uuid::parse_str(&sid).expect("preallocated id is a canonical UUID"); + + // 2. Registry row carries the id (this is GET /api/terminals rung 0, + // terminals.rs:686-698 — populating it makes sessionRef real there + // with zero changes to terminals.rs). + let terminal_id = body["data"]["paneContent"]["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + let row = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == terminal_id) + .expect("registry row exists"); + assert_eq!(row.resume_session_id.as_deref(), Some(sid.as_str())); + + // 3. argv proof: `claude --session-id ` (LaunchIntent::Start), + // NOT `--resume` and NOT bare argv. + let argv = wait_for_captured_argv(&argv_capture_path); // per :3321's capture idiom + let pos = argv.iter().position(|a| a == "--session-id").expect("--session-id in argv"); + assert_eq!(argv.get(pos + 1).map(String::as_str), Some(sid.as_str())); + assert!(!argv.iter().any(|a| a == "--resume"), "fresh create must not resume: {argv:?}"); + + registry.kill(&terminal_id); +} +``` + +Notes for the implementer: +- If the response JSON shape differs (e.g. `paneContent` nested differently), print `body` from the failing run and adjust the *accessor paths only* — the three assertions (response sessionRef, registry row, argv pair) are the contract. +- If no argv-capture helper exists locally in this `mod tests`, port `write_fake_claude_capture()` from `crates/freshell-ws/tests/claude_session_rebind.rs:53-79` (a `#!/bin/sh` script dumping `"$@"` to `$CLAUDE_ARGV_CAPTURE_PATH` then `exec sleep 300`, chmod 0755, written to `std::env::temp_dir()`), and point the spec's `default_cmd` at it directly (avoid `env_var`/process-global env in this shared test binary if a direct path works). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p freshell-freshagent create_fresh_claude_tab_preallocates_session_identity -- --exact` +Expected: FAIL — `sessionRef` absent from `paneContent` (today's behavior: no mint, `resume_session_id = None`). + +- [ ] **Step 3: Implement the mint in `spawn_terminal_pane`** + +In `crates/freshell-freshagent/src/terminal_tabs.rs`, immediately after the `derive_resume_identity` call at `:767`: + +```rust + let (mut resume_session_id, accepted_session_ref) = derive_resume_identity(body, &mode)?; + + // Fresh-claude preallocation (kata hbsa): WS parity. The WS door's + // fresh-claude special case (freshell-ws/src/terminal.rs, LIVE-PATH LAW + // spec §2.1(3)) mints a server-preallocated --session-id for every fresh + // claude create; this REST door historically did not (legacy router.ts + // lineage), leaving REST claude panes un-resumable and invisible to the + // A13 live-owner guard. Same predicate, same mint, both doors. + // + // PIN 2 (eaa25b7d): `claude_fresh_prealloc` marks that THIS create minted + // the id — the pre-spawn ledger write and its spawn-failure delete (Task 5 + // call sites in settle_gated_create) are BOTH gated on this exact flag, + // never on `mode == "claude"`. + let claude_fresh_prealloc = freshell_platform::should_preallocate_fresh_claude( + &mode, + body.get("restore").and_then(serde_json::Value::as_bool), + body.get("sessionRef").is_some(), + resume_session_id.as_deref(), + ); + if claude_fresh_prealloc { + resume_session_id = Some(uuid::Uuid::new_v4().to_string()); + } +``` + +(`uuid::Uuid::new_v4()` is already in use in this file at `:831` for the amplifier mint — same import path.) + +Note the predicate uses `body.get("sessionRef").is_some()` — the raw-field check, exact WS semantics (`create.session_ref.is_none()`), not `accepted_session_ref.is_some()`: a wire `sessionRef` of ANY provider disables the mint on both doors. + +Placement matters: this sits BEFORE the amplifier block (`:794-950`) and BEFORE the D7 guard (`:961-990`). The D7/D8 machinery keys off `accepted_session_ref`, which is `None` here, so a minted id claims no lease and trips no guard — matching the WS doctrine that fresh preallocs never route through D8. + +- [ ] **Step 4: Thread the flag and flip the launch intent** + +1. Add `claude_fresh_prealloc: bool` to `struct GatedSettleInputs` (`:1130`) and to its destructuring at `:1168`; populate it at the `tokio::spawn(settle_gated_create(GatedSettleInputs { .. }))` call site (`:1096`). +2. In `settle_gated_create`, replace `:1322-1339` (the `CliLaunchInputs` literal's intent field and its stale comment): + +```rust + resume_session_id: resume_session_id.as_deref(), + // WS-parity launch intent (kata hbsa). `Start` selects claude's + // `create_session_args` template (`--session-id {{sessionId}}`, + // cli_launch_goldens.rs:52) for the id THIS create minted; + // everything else is a genuine resume — an accepted `sessionRef`, + // a legacy `resumeSessionId`, or the fresh-amplifier mint at + // `spawn_terminal_pane` (:820-831), which deliberately keeps + // `Resume` (amplifier's manifest has `resume_args` only and + // `Start` would hard-error `StartIntentUnsupported`, + // cli_launch.rs:496-510). Mirrors the WS door + // (freshell-ws/src/terminal.rs fresh-claude special case). + launch_intent: if claude_fresh_prealloc { + LaunchIntent::Start + } else { + LaunchIntent::Resume + }, +``` + +This deletes the old "this path never mints its OWN preallocated session id … matches `router.ts`" comment — that legacy split is exactly what this task removes (and its `terminal.rs:749-762` cross-reference was stale anyway). + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test -p freshell-freshagent create_fresh_claude_tab_preallocates_session_identity -- --exact` +Expected: PASS + +- [ ] **Step 6: Reconcile the `tab_create_missing_session_identity` warn site and its pinned tests** + +Run the two existing pinning tests first: + +```bash +cargo test -p freshell-freshagent create_fresh_session_provider_tab_without_identity_warns_invariant -- --exact +cargo test -p freshell-freshagent create_tab_with_identity_or_shell_mode_does_not_warn_invariant -- --exact +``` + +The warn at `:1792-1805` fires when the create *request* carried neither `sessionRef` nor `resumeSessionId`. A fresh claude create still carries neither in the request, but it now HAS identity (the mint). Update the condition to consult the spawn outcome, so the alarm means what it says ("the pane has no identity key"): + +```rust + if is_session_provider_mode(&mode) + && payload.get("sessionRef").is_none() + && payload.get("resumeSessionId").is_none() + // kata hbsa: fresh claude REST creates now mint their own identity + // (paneContent.sessionRef) — a create that ended up with a real + // sessionRef is not identity-less, so it must not alarm. + && payload + .get("paneContent") + .and_then(|c| c.get("sessionRef")) + .is_none() + { +``` + +(If `payload` in that function does not embed `paneContent`, gate on the spawn result's sessionRef instead — the `TerminalSpawnResult`/local variable that carried `paneContent` into the payload a few lines earlier in `create_terminal_tab`. The semantic is fixed: *skip the warn when the finished create has a sessionRef*.) + +Then fix the tests: +- If `create_fresh_session_provider_tab_without_identity_warns_invariant` used `mode:"claude"`, switch it to a session-provider mode that still has no create-time identity source (`"gemini"`) — the alarm is still correct for those. +- Add a new negative case (same file, next to the existing negative test, reusing its warn-capture mechanism): + +```rust +#[tokio::test] +async fn create_fresh_claude_tab_does_not_warn_missing_identity() { + // kata hbsa: the mint closes the identity gap, so the invariant alarm + // must stay quiet for fresh claude REST creates. + // (Same harness as create_tab_with_identity_or_shell_mode_does_not_warn_invariant, + // with a fresh {mode:"claude"} body and no sessionRef/resumeSessionId.) + ... +} +``` + +Run: `cargo test -p freshell-freshagent -- warns_invariant does_not_warn` +Expected: PASS (all three). + +- [ ] **Step 7: Run the whole freshagent suite** + +Run: `cargo test -p freshell-freshagent` +Expected: PASS. If any existing test pinned "fresh claude REST create has no sessionRef" (search failures for `sessionRef`), flip its expectation deliberately and note it in the commit message — that was the bug's pin, not a contract. + +- [ ] **Step 8: Commit** + +```bash +git add crates/freshell-freshagent/src/terminal_tabs.rs +git commit -m "fix(freshagent): REST claude creates mint a preallocated --session-id (WS parity, kata hbsa)" +``` + +--- + +### Task 3: Split and respawn entry points carry the same identity + +**Files:** +- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` `mod tests` (tests only — `pane_ops::split_pane` (`pane_ops.rs:156`) and `pane_ops::respawn_pane` (`pane_ops.rs:716`) both funnel through `spawn_terminal_pane`, so Task 2 already fixed them; this task PINS that) + +**Interfaces:** +- Consumes: Task 2's mint. The router under test is `crate::router(state)`, which registers `/api/panes/:id/split` and `/api/panes/:id/respawn` (`pane_ops.rs:56-61`). +- Produces: nothing new — regression pins only. + +- [ ] **Step 1: Write the failing-or-passing pin tests** + +Same harness as Task 2's test (fake claude spec with `create_session_args`; the split/respawn spawns will each need their own argv-capture file if argv is asserted — asserting `sessionRef` + registry row is sufficient here and avoids capture-path races): + +```rust +#[tokio::test] +async fn split_pane_claude_preallocates_fresh_session_identity() { + // kata hbsa P2: POST /api/panes/:id/split shares spawn_terminal_pane, + // so a claude split must mint its OWN fresh identity (distinct from + // the source pane's). + let (state, registry, _capture) = state_with_claude_capture_spec(); + let router = app(state); + + let (status, tab) = post(router.clone(), "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; + assert_eq!(status, axum::http::StatusCode::OK); + let pane_id = tab["data"]["paneContent"]["paneId"].as_str() + .or_else(|| tab["data"]["paneId"].as_str()) + .expect("pane id in create response").to_string(); + let first_sid = tab["data"]["paneContent"]["sessionRef"]["sessionId"] + .as_str().expect("first pane minted").to_string(); + + let (status, split) = post(router, &format!("/api/panes/{pane_id}/split"), + serde_json::json!({"mode":"claude"}), true).await; + assert_eq!(status, axum::http::StatusCode::OK, "split failed: {split}"); + let split_ref = split["data"]["paneContent"]["sessionRef"].clone(); + assert_eq!(split_ref["provider"], serde_json::json!("claude"), "split sessionRef: {split}"); + let split_sid = split_ref["sessionId"].as_str().expect("split minted").to_string(); + uuid::Uuid::parse_str(&split_sid).expect("canonical UUID"); + assert_ne!(split_sid, first_sid, "split must mint its OWN identity"); + + // Registry rows for BOTH panes carry their ids. + let rows = registry.identity_probe_rows(); + assert_eq!( + rows.iter().filter(|r| r.resume_session_id.is_some()).count(), + 2, + "both claude panes carry resume identity: {rows:?}" + ); + for r in rows { registry.kill(&r.terminal_id); } +} + +#[tokio::test] +async fn respawn_pane_claude_ends_with_session_identity() { + // kata hbsa P2: POST /api/panes/:id/respawn also funnels through + // spawn_terminal_pane. The pin is the identity GAP being closed: the + // respawned claude pane must end with a real sessionRef (whether the + // respawn resumes the prior id or mints fresh is respawn policy, pinned + // elsewhere — the bug here was ending with NO identity at all). + let (state, registry, _capture) = state_with_claude_capture_spec(); + let router = app(state); + + let (status, tab) = post(router.clone(), "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; + assert_eq!(status, axum::http::StatusCode::OK); + let pane_id = tab["data"]["paneContent"]["paneId"].as_str() + .or_else(|| tab["data"]["paneId"].as_str()) + .expect("pane id").to_string(); + + let (status, respawned) = post(router, &format!("/api/panes/{pane_id}/respawn"), + serde_json::json!({}), true).await; + assert_eq!(status, axum::http::StatusCode::OK, "respawn failed: {respawned}"); + let sid = respawned["data"]["paneContent"]["sessionRef"]["sessionId"] + .as_str().expect("respawned pane has identity").to_string(); + uuid::Uuid::parse_str(&sid).expect("canonical UUID"); + + for r in registry.identity_probe_rows() { registry.kill(&r.terminal_id); } +} +``` + +(Adjust response accessor paths against actual bodies as in Task 2; look at the existing split/respawn tests in `pane_ops.rs`/`terminal_tabs.rs mod tests` for the exact request/response shapes — `pane_ops.rs:1011` shows a helper POSTing `/api/tabs` you can crib.) + +- [ ] **Step 2: Run them** + +Run: `cargo test -p freshell-freshagent -- split_pane_claude respawn_pane_claude` +Expected: PASS (Task 2 fixed the shared pipeline). If respawn FAILS because `respawn_pane` rebuilds a body that carries the old pane's `sessionRef`/`resumeSessionId` and the prior session is refused by a guard, that's a real finding: fix by letting the respawn body flow through `derive_resume_identity` + the Task 2 mint unchanged (a respawn body WITH identity resumes, one WITHOUT mints) and record the behavior in the test's comment. + +- [ ] **Step 3: Commit** + +```bash +git add crates/freshell-freshagent/src/terminal_tabs.rs +git commit -m "test(freshagent): pin claude identity mint on REST split and respawn entry points" +``` + +--- + +### Task 4: `PaneIdentityBinder` seam + `LedgerPaneIdentityBinder` + +**Files:** +- Modify: `crates/freshell-terminal/src/registry.rs` (trait, next to `SessionIdentityLookup` at `:638-641`) +- Modify: `crates/freshell-terminal/Cargo.toml` (add `async-trait` — use the workspace-dep form other crates use, e.g. copy the line from `crates/freshell-freshagent/Cargo.toml`) +- Create: `crates/freshell-ws/src/pane_identity_binder.rs` +- Modify: `crates/freshell-ws/src/lib.rs` (`pub mod pane_identity_binder;`) + +**Interfaces:** +- Consumes: `crate::identity::TerminalIdentityRegistry` (`identity.rs:33-56`, methods `upsert`), `crate::pane_ledger::{PaneLedger, BindingWrite}` (`pane_ledger.rs:144-152`, `:358`), the `MARKER_MODES` list + `record_pending` call currently at `terminal.rs:2523-2540`, and `freshell-ws`'s existing `now_ms()` helper (grep `fn now_ms` in the crate; reuse, don't redefine). +- Produces (Task 5 and Task 6 depend on these exact signatures): + +```rust +// crates/freshell-terminal/src/registry.rs +#[async_trait::async_trait] +pub trait PaneIdentityBinder: Send + Sync { + /// PIN 2 durability-before-argv: durable claude binding row written + /// BEFORE the spawn makes the preallocated id observable. Callers gate + /// this on their fresh-prealloc flag ONLY (eaa25b7d). + async fn record_prespawn_claude_binding( + &self, + session_id: &str, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + create_request_id: Option<&str>, + ); + /// Compensating delete when the spawn that minted the id fails. + /// MUST be gated on the SAME predicate as the record (eaa25b7d). + async fn delete_prespawn_claude_binding(&self, session_id: &str); + /// Post-spawn identity registration, mirroring the WS post-spawn block + /// (freshell-ws/src/terminal.rs): identity row + durable binding for any + /// non-shell create with a session id; pending marker for the + /// locator-resolved providers (codex/opencode/amplifier) without one. + async fn register_create_identity( + &self, + terminal_id: &str, + mode: &str, + resume_session_id: Option<&str>, + cwd: Option<&str>, + create_request_id: Option<&str>, + ); +} +``` + +and `freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new(identity: TerminalIdentityRegistry, ledger: Arc) -> LedgerPaneIdentityBinder` implementing it. + +- [ ] **Step 1: Write the failing unit tests for the impl** + +Create `crates/freshell-ws/src/pane_identity_binder.rs` starting with tests (temp-dir ledger per the `pane_ledger_tests.rs:9-21` `temp_root` idiom — pid + atomic counter; NEVER a real `$HOME` path): + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::pane_ledger::{PaneLedger, RowState}; + use std::sync::Arc; + + fn temp_root(label: &str) -> std::path::PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = std::env::temp_dir() + .join(format!("pane-identity-binder-{label}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp root"); + dir + } + + fn binder(label: &str) -> (LedgerPaneIdentityBinder, Arc, crate::identity::TerminalIdentityRegistry, std::path::PathBuf) { + let dir = temp_root(label); + let ledger = Arc::new(PaneLedger::new(Some(dir.clone()))); + let identity = crate::identity::TerminalIdentityRegistry::default(); + (LedgerPaneIdentityBinder::new(identity.clone(), Arc::clone(&ledger)), ledger, identity, dir) + } + + const SID: &str = "29a53649-1111-4222-8333-444455556666"; + + #[tokio::test] + async fn prespawn_binding_writes_a_bound_claude_row_and_delete_removes_it() { + use freshell_terminal::registry::PaneIdentityBinder as _; + let (b, ledger, _identity, dir) = binder("prespawn"); + b.record_prespawn_claude_binding(SID, "t-rest-1", "claude", Some("/tmp"), Some("req-1")).await; + let row = ledger.load_binding("claude", SID).expect("pre-spawn row exists (PIN 2)"); + assert_eq!(row.live_terminal_id.as_deref(), Some("t-rest-1")); + assert_eq!(row.state, RowState::Bound); + + b.delete_prespawn_claude_binding(SID).await; + assert!(ledger.load_binding("claude", SID).is_none(), "failure-delete removes the row"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn register_create_identity_writes_identity_row_and_binding() { + use freshell_terminal::registry::PaneIdentityBinder as _; + let (b, ledger, identity, dir) = binder("register"); + b.register_create_identity("t-rest-2", "claude", Some(SID), Some("/tmp"), Some("req-2")).await; + let row = identity.get("t-rest-2").expect("identity row (the A13/signal-drain prerequisite)"); + assert_eq!(row.provider.as_deref(), Some("claude")); + assert_eq!(row.session_id.as_deref(), Some(SID)); + let binding = ledger.load_binding("claude", SID).expect("post-spawn binding row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some("t-rest-2")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn register_create_identity_skips_shell_and_marks_pending_for_marker_modes() { + use freshell_terminal::registry::PaneIdentityBinder as _; + let (b, ledger, identity, dir) = binder("markers"); + // shell: nothing at all + b.register_create_identity("t-shell", "shell", None, None, None).await; + assert!(identity.get("t-shell").is_none()); + // codex without an id: pending marker (locator lane resolves later), + // exactly the WS MARKER_MODES arm (terminal.rs:2523-2540). + b.register_create_identity("t-codex", "codex", None, Some("/tmp"), Some("req-3")).await; + assert!(identity.get("t-codex").is_none(), "no premature identity row"); + assert!(ledger.has_pending("t-codex"), "pending marker written"); // use the ledger's actual pending-read API — see pane_ledger.rs `pending/` store and how terminal.rs/pane_ledger_tests assert markers; adjust the accessor name to match. + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn ledger_write_failure_never_panics_the_create() { + use freshell_terminal::registry::PaneIdentityBinder as _; + // A disabled ledger (or an unwritable root) must degrade to a warn, + // never an Err/panic — failure never blocks the create. + let identity = crate::identity::TerminalIdentityRegistry::default(); + let b = LedgerPaneIdentityBinder::new(identity.clone(), Arc::new(PaneLedger::disabled())); + b.record_prespawn_claude_binding(SID, "t-x", "claude", None, None).await; + b.register_create_identity("t-x", "claude", Some(SID), None, None).await; + // identity row still lands even when durability is degraded: + assert!(identity.get("t-x").is_some()); + } +} +``` + +(For the pending-marker assertion: open `crates/freshell-ws/src/pane_ledger.rs` and use whatever read API the existing marker tests use — the store is `pending/.json`; if no read helper exists, assert the file's existence under `dir.join("pending")` by listing that directory.) + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cargo test -p freshell-ws pane_identity_binder` +Expected: FAIL to compile — trait and struct don't exist. + +- [ ] **Step 3: Implement trait + impl** + +1. `crates/freshell-terminal/Cargo.toml`: add `async-trait` (same form as `freshell-freshagent`'s dependency on it). +2. `crates/freshell-terminal/src/registry.rs`: add the trait exactly as specified in **Interfaces** above, directly below `SessionIdentityLookup` (`:638-641`), with a doc comment naming both consumers (REST spawn pipeline) and the producer (`freshell-ws`). +3. `crates/freshell-ws/src/pane_identity_binder.rs`: + +```rust +//! Write-side pane-identity seam for the REST spawn pipeline (kata hbsa). +//! +//! `freshell-freshagent` cannot depend on `freshell-ws` (circular), so it +//! cannot write `TerminalIdentityRegistry` rows or `PaneLedger` bindings +//! directly — the exact gap that left REST claude panes un-resumable and +//! invisible to A13. This impl mirrors the WS create path's identity writes +//! (`terminal.rs` PIN2_CLAUDE_PRE_SPAWN_BINDING block, its failure-delete +//! twin, and the post-spawn identity/binding/pending block) behind the +//! `freshell_terminal::registry::PaneIdentityBinder` trait, wired into +//! `FreshAgentState` by `freshell-server::main` (the `SessionIdentityLookup` +//! precedent, read-side twin). +//! +//! Failure policy: ledger writes are best-effort — warn on the +//! `freshell_ws::invariants` target and proceed; a create is never blocked +//! by durability degradation. (The WS rung additionally broadcasts +//! `DurabilityDegraded` via `surface_write_failure`, which needs `&WsState`; +//! this seam has no `WsState`, and log-only is strictly better than the +//! nothing-at-all the REST lane wrote before.) + +use std::sync::Arc; + +use crate::identity::TerminalIdentityRegistry; +use crate::pane_ledger::{BindingWrite, PaneLedger}; + +pub struct LedgerPaneIdentityBinder { + identity: TerminalIdentityRegistry, + ledger: Arc, +} + +impl LedgerPaneIdentityBinder { + pub fn new(identity: TerminalIdentityRegistry, ledger: Arc) -> Self { + Self { identity, ledger } + } + + fn warn_write_failure(terminal_id: &str, what: &str, err: &std::io::Error) { + tracing::warn!( + target: "freshell_ws::invariants", + terminal_id = %terminal_id, + error = %err, + "pane_ledger_write_failed: {what} (REST rung; create proceeds, durability degraded)" + ); + } +} + +#[async_trait::async_trait] +impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinder { + async fn record_prespawn_claude_binding( + &self, + session_id: &str, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + create_request_id: Option<&str>, + ) { + let ledger = Arc::clone(&self.ledger); + let (sid, tid, m) = (session_id.to_string(), terminal_id.to_string(), mode.to_string()); + let (c, rid) = (cwd.map(str::to_string), create_request_id.map(str::to_string)); + let now = crate::now_ms(); // reuse the crate's existing helper; adjust path to where it lives + let tid_for_log = tid.clone(); + let result = tokio::task::spawn_blocking(move || { + ledger.record_binding(&BindingWrite { + provider: "claude", + session_id: &sid, + terminal_id: &tid, + mode: &m, + cwd: c.as_deref(), + create_request_id: rid.as_deref(), + now_ms: now, + }) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + if let Err(err) = result { + Self::warn_write_failure(&tid_for_log, "pre-spawn claude binding (PIN 2)", &err); + } + } + + async fn delete_prespawn_claude_binding(&self, session_id: &str) { + let ledger = Arc::clone(&self.ledger); + let sid = session_id.to_string(); + let result = tokio::task::spawn_blocking(move || ledger.delete_binding("claude", &sid)) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + if let Err(err) = result { + Self::warn_write_failure("(spawn-failed)", "pre-spawn binding failure-delete", &err); + } + } + + async fn register_create_identity( + &self, + terminal_id: &str, + mode: &str, + resume_session_id: Option<&str>, + cwd: Option<&str>, + create_request_id: Option<&str>, + ) { + // Mirrors terminal.rs's post-spawn block: identity row + binding for + // any non-shell create carrying a session id; pending marker for the + // identity-in-flight providers. Port the bodies from + // terminal.rs:2453-2540 (terminal_meta_record_for_create semantics, + // identity.upsert, record_binding, MARKER_MODES/record_pending), + // substituting self.identity / self.ledger and the warn above for + // surface_write_failure. Export MARKER_MODES from terminal.rs + // (`pub(crate) const` -> keep crate-visible and reference it here) + // rather than duplicating the list. + if mode == "shell" { + return; + } + if let Some(session_id) = resume_session_id.filter(|s| !s.is_empty()) { + self.identity.upsert(terminal_id, Some(mode), Some(session_id), cwd, crate::now_ms()); + let ledger = Arc::clone(&self.ledger); + let (sid, tid, m) = (session_id.to_string(), terminal_id.to_string(), mode.to_string()); + let (c, rid) = (cwd.map(str::to_string), create_request_id.map(str::to_string)); + let now = crate::now_ms(); + let tid_for_log = tid.clone(); + let result = tokio::task::spawn_blocking(move || { + ledger.record_binding(&BindingWrite { + provider: &m.clone(), + session_id: &sid, + terminal_id: &tid, + mode: &m, + cwd: c.as_deref(), + create_request_id: rid.as_deref(), + now_ms: now, + }) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + if let Err(err) = result { + Self::warn_write_failure(&tid_for_log, "post-spawn identity binding", &err); + } + } else if crate::terminal::MARKER_MODES.contains(&mode) { + // PORT (not new design): the pending-marker arm — copy the exact + // record_pending call from terminal.rs's MARKER_MODES block + // (:2523-2540): same arguments, wrapped in spawn_blocking, with + // the warn_write_failure policy above replacing + // surface_write_failure. Make MARKER_MODES `pub(crate)` in + // terminal.rs if it is private today. + } + } +} +``` + +The two `...port from terminal.rs...` spots are literal ports: open `crates/freshell-ws/src/terminal.rs:2453-2540`, copy the bodies, substitute `self.identity`/`self.ledger`, and adjust visibility of `MARKER_MODES` (make it `pub(crate)` if it isn't) and `now_ms` (reuse wherever it's defined in the crate). Do NOT re-derive the provider-vs-mode choice — keep exactly what the WS block does (`provider = mode` on the post-spawn write). + +4. `crates/freshell-ws/src/lib.rs`: `pub mod pane_identity_binder;` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p freshell-ws pane_identity_binder` +Expected: PASS (4 tests). + +- [ ] **Step 5: Full-crate checks for the touched crates** + +Run: `cargo test -p freshell-terminal --all-targets && cargo clippy -p freshell-terminal -p freshell-ws --all-targets -- -D warnings` +Expected: PASS, no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-terminal/Cargo.toml crates/freshell-terminal/src/registry.rs \ + crates/freshell-ws/src/pane_identity_binder.rs crates/freshell-ws/src/lib.rs \ + crates/freshell-ws/src/terminal.rs Cargo.lock +git commit -m "feat(ws,terminal): PaneIdentityBinder seam — write-side identity/ledger bridge for the REST lane" +``` + +--- + +### Task 5: Thread the binder through the REST spawn pipeline + +**Files:** +- Modify: `crates/freshell-freshagent/src/lib.rs` (state field + builder) +- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` (`GatedSettleInputs`, three call sites in `settle_gated_create`, `mod tests`) +- Modify: `crates/freshell-freshagent/Cargo.toml` (add `async-trait` if not already a direct dep) +- Modify: `crates/freshell-server/src/main.rs` (wiring) + +**Interfaces:** +- Consumes: `freshell_terminal::registry::PaneIdentityBinder` (Task 4), `GatedSettleInputs.claude_fresh_prealloc` (Task 2), `freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder` (Task 4, wiring only — freshagent code references only the trait). +- Produces: `FreshAgentState::with_pane_identity_binder(self, binder: Arc) -> Self`; call ordering inside `settle_gated_create`: `record_prespawn_claude_binding` (fresh preallocs only) → spawn → on failure `delete_prespawn_claude_binding` (same gate) / on success `register_create_identity` (all creates). Task 6's e2e tests and the production wiring rely on this ordering. + +- [ ] **Step 1: Write the failing unit tests with a recording fake binder** + +In `crates/freshell-freshagent/src/terminal_tabs.rs` `mod tests`: + +```rust +#[derive(Default)] +struct RecordingBinder { + events: std::sync::Mutex>, +} + +impl RecordingBinder { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +#[async_trait::async_trait] +impl freshell_terminal::registry::PaneIdentityBinder for RecordingBinder { + async fn record_prespawn_claude_binding( + &self, session_id: &str, terminal_id: &str, _mode: &str, + _cwd: Option<&str>, _create_request_id: Option<&str>, + ) { + self.events.lock().unwrap().push(format!("prespawn:{terminal_id}:{session_id}")); + } + async fn delete_prespawn_claude_binding(&self, session_id: &str) { + self.events.lock().unwrap().push(format!("delete:{session_id}")); + } + async fn register_create_identity( + &self, terminal_id: &str, mode: &str, resume_session_id: Option<&str>, + _cwd: Option<&str>, _create_request_id: Option<&str>, + ) { + self.events.lock().unwrap().push(format!( + "register:{terminal_id}:{mode}:{}", resume_session_id.unwrap_or("-") + )); + } +} + +#[tokio::test] +async fn fresh_claude_rest_create_drives_binder_prespawn_then_register() { + // kata hbsa P1: PIN 2 ordering on the REST rung — durable pre-spawn + // binding, then spawn, then identity registration. + let binder = std::sync::Arc::new(RecordingBinder::default()); + let (state, registry, _capture) = state_with_claude_capture_spec(); + let state = state.with_pane_identity_binder(binder.clone()); + + let (status, body) = post(app(state), "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + let sid = body["data"]["paneContent"]["sessionRef"]["sessionId"].as_str().unwrap().to_string(); + let tid = body["data"]["paneContent"]["terminalId"].as_str().unwrap().to_string(); + + let events = binder.events(); + let prespawn = events.iter().position(|e| e == &format!("prespawn:{tid}:{sid}")) + .unwrap_or_else(|| panic!("prespawn event missing: {events:?}")); + let register = events.iter().position(|e| e == &format!("register:{tid}:claude:{sid}")) + .unwrap_or_else(|| panic!("register event missing: {events:?}")); + assert!(prespawn < register, "PIN 2: durability before registration: {events:?}"); + assert!(!events.iter().any(|e| e.starts_with("delete:")), "no failure-delete on success"); + + registry.kill(&tid); +} + +#[tokio::test] +async fn resume_claude_rest_create_registers_identity_without_prespawn_write() { + // eaa25b7d scoping on the REST rung: a RESUME create never writes the + // pre-spawn row (it belongs to the prior epoch) but DOES register + // identity post-spawn — this closes the resume-direction half of the + // gap (REST resumes previously died at restart: pane_ledger_restore.rs). + let binder = std::sync::Arc::new(RecordingBinder::default()); + let (state, registry, _capture) = state_with_claude_capture_spec(); + let state = state.with_pane_identity_binder(binder.clone()); + + const S: &str = "29a53649-2222-4333-8444-555566667777"; + // Mirror the request shape of the existing passing with-identity create + // test (create_tab_with_identity_or_shell_mode_does_not_warn_invariant). + let (status, body) = post(app(state), "/api/tabs", + serde_json::json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": {"provider": "claude", "sessionId": S}, + }), true).await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + let tid = body["data"]["paneContent"]["terminalId"].as_str().unwrap().to_string(); + + let events = binder.events(); + assert!(!events.iter().any(|e| e.starts_with("prespawn:")), + "resume creates must not write the pre-spawn row (eaa25b7d): {events:?}"); + assert!(events.contains(&format!("register:{tid}:claude:{S}")), "{events:?}"); + + registry.kill(&tid); +} + +#[tokio::test] +async fn failed_fresh_claude_spawn_deletes_its_prespawn_binding() { + // eaa25b7d symmetry: the failure-delete fires with the SAME gate as the + // write, for the id THIS create minted. + let binder = std::sync::Arc::new(RecordingBinder::default()); + // A spec whose command cannot spawn: point default_cmd at a + // nonexistent path (no env_var), same spec shape as the capture spec. + let (state, _registry) = state_with_broken_claude_spec(); // build alongside state_with_claude_capture_spec + let state = state.with_pane_identity_binder(binder.clone()); + + let (status, _body) = post(app(state), "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; + assert!(!status.is_success(), "spawn must fail"); + + let events = binder.events(); + let prespawn_sid = events.iter().find_map(|e| e.strip_prefix("prespawn:") + .and_then(|rest| rest.split(':').nth(1)).map(str::to_string)) + .unwrap_or_else(|| panic!("prespawn happened before the spawn attempt: {events:?}")); + assert!(events.contains(&format!("delete:{prespawn_sid}")), + "failure-delete for the minted id: {events:?}"); + assert!(!events.iter().any(|e| e.starts_with("register:")), "{events:?}"); +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p freshell-freshagent -- drives_binder registers_identity_without_prespawn deletes_its_prespawn` +Expected: FAIL to compile — `with_pane_identity_binder` doesn't exist. + +- [ ] **Step 3: Implement state field + builder** + +In `crates/freshell-freshagent/src/lib.rs`, next to the `session_identity` field (`lib.rs:142-148`) add: + +```rust + /// Write-side pane-identity seam (kata hbsa): lets the REST spawn + /// pipeline write TerminalIdentityRegistry rows and PaneLedger bindings + /// across the freshagent->ws crate boundary. Read-side twin: + /// `session_identity`. `None` (tests without identity concerns) = the + /// legacy no-write behavior. + pub(crate) pane_identity: Option>, +``` + +initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder next to `with_session_identity` (`:455-461`): + +```rust + pub fn with_pane_identity_binder( + mut self, + binder: Arc, + ) -> Self { + self.pane_identity = Some(binder); + self + } +``` + +- [ ] **Step 4: Implement the three call sites in `settle_gated_create`** + +1. Add `pane_identity: Option>` to `GatedSettleInputs` (`:1130`), populate from `state.pane_identity.clone()` at the `tokio::spawn` site (`:1096`), destructure at `:1168`. + +2. **Pre-spawn** (between the exit hook construction ending ~`:1462` and the `spawn_blocking` at `:1481`): + +```rust + // PIN2_CLAUDE_PRE_SPAWN_BINDING (REST rung, kata hbsa): durability + // before observability — the spawn below puts the preallocated id in + // argv; a SIGKILL right after spawn must still find a durable ledger + // row. Gated on `claude_fresh_prealloc` ONLY (eaa25b7d: this create + // minted the id, so the row is provably exclusive; a resume-create's + // row belongs to the prior epoch). Mirrors + // freshell-ws/src/terminal.rs's PIN2 block. + if claude_fresh_prealloc { + if let (Some(binder), Some(session_id)) = + (pane_identity.as_ref(), resume_session_id.as_deref()) + { + binder + .record_prespawn_claude_binding( + session_id, + &terminal_id, + &mode, + cwd.as_deref(), + Some(&create_request_id), + ) + .await; + } + } +``` + +(Use the locally destructured variable names — `terminal_id`/`cwd`/`create_request_id` exist under whatever names `GatedSettleInputs` destructures them to at `:1168`; keep the argument *meanings* fixed.) + +3. **Spawn-failure arm** (inside the error branch of the `create_result` match after `:1481-1493`, before the error is returned): + +```rust + // PIN 2 compensating delete — SAME gate as the write (eaa25b7d). + if claude_fresh_prealloc { + if let (Some(binder), Some(session_id)) = + (pane_identity.as_ref(), resume_session_id.as_deref()) + { + binder.delete_prespawn_claude_binding(session_id).await; + } + } +``` + +4. **Post-spawn** (after `registry.set_meta(..)` at `:1589-1595`, next to `arm_locators_for_fresh_pane` at `:1602-1608`): + +```rust + // Identity registration (kata hbsa): identity row + durable binding + // for any create with a session id (fresh mint OR resume — the + // resume half is what made REST-resumed claude panes die at + // restart), pending marker for the locator-resolved providers. + // The identity row is the prerequisite for BOTH the A13 live-owner + // guard's identity arm and the SessionStart signal drain acting + // (claude_signal.rs retains signals for identity-less panes forever). + if let Some(binder) = pane_identity.as_ref() { + binder + .register_create_identity( + &terminal_id, + &mode, + resume_session_id.as_deref(), + cwd.as_deref(), + Some(&create_request_id), + ) + .await; + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cargo test -p freshell-freshagent -- drives_binder registers_identity_without_prespawn deletes_its_prespawn` +Expected: PASS. Then `cargo test -p freshell-freshagent` — full crate PASS. + +- [ ] **Step 6: Wire production in `freshell-server::main`** + +In `crates/freshell-server/src/main.rs`, find where the freshagent state is built with `.with_session_identity(..)` (`main.rs:286`) and where the WS `TerminalIdentityRegistry` and `Arc` locals are constructed (they're the same values passed into `WsState`). Chain: + +```rust + .with_pane_identity_binder(std::sync::Arc::new( + freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new( + identity.clone(), // the SAME registry WsState.identity uses + std::sync::Arc::clone(&pane_ledger), // the SAME ledger WsState.pane_ledger uses + ), + )) +``` + +(Match the actual local variable names in `main.rs`. Sharing the same instances is the whole point — REST-written rows must be visible to the WS guard/drain and vice versa.) + +Run: `cargo build -p freshell-server` +Expected: compiles clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/lib.rs crates/freshell-freshagent/src/terminal_tabs.rs \ + crates/freshell-freshagent/Cargo.toml crates/freshell-server/src/main.rs Cargo.lock +git commit -m "feat(freshagent): REST creates write identity rows and ledger bindings via PaneIdentityBinder (kata hbsa)" +``` + +--- + +### Task 6: End-to-end regression tests on a merged REST+WS server + +**Files:** +- Create: `crates/freshell-ws/tests/rest_claude_identity.rs` +- Modify (only if red): `crates/freshell-ws/tests/pane_ledger_restore.rs:237-302` + +**Interfaces:** +- Consumes: everything from Tasks 1–5; harness idioms from `crates/freshell-ws/tests/rest_ws_shared_gate.rs:31-96` (merged `freshell_ws::router` + `freshell_freshagent::router` on one listener, shared auth token / broadcast bus / `TerminalRegistry`) and `crates/freshell-ws/tests/common/mod.rs` (`connect_and_capture_inventory`, `next_frame_of_type`, `session_ref_of`, `sleeper_cli_spec`, `AUTH_TOKEN`). +- Produces: the Required-Outcome-4 regression suite (4a, 4b, 4c) + REST-resume durable-identity pin. + +- [ ] **Step 1: Write the test file (all tests red until reviewed against actual harness details, then green)** + +`crates/freshell-ws/tests/rest_claude_identity.rs`. Whole file `#[cfg(unix)]`-gated (established style). Skeleton — the spawn helper merges the two routers exactly like `rest_ws_shared_gate.rs` does, EXTENDED with: a temp-dir `PaneLedger` (shared `Arc` into `WsState` and the binder), the real `LedgerPaneIdentityBinder` chained onto the freshagent state via `.with_pane_identity_binder(..)` AND `.with_session_identity(..)` + `.with_terminal_registry(..)` as `rest_ws_shared_gate.rs`/`main.rs` do, and a returned `WsState` clone (copy the `spawn_server_with_specs_and_state` / `spawn_server_returning_state` pattern for how `WsState` is built and returned): + +```rust +#![cfg(unix)] +//! kata hbsa regression suite: REST-created claude panes carry full session +//! identity (Required Outcomes P1/2/4). Isolation rules (AGENTS.md + the +//! live 3002 server): temp-dir signal root via ClaudeSignalWatcher::new, +//! temp-dir lock-free PaneLedger::new, synchronous drains only. + +mod common; + +use std::sync::Arc; +use std::time::Duration; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +struct Harness { + base_url: String, // http://{addr} + ws: common::TestWs, // connected + hello'd + registry: freshell_terminal::TerminalRegistry, + ws_state: freshell_ws::WsState, // adjust path/visibility per spawn_server_with_specs_and_state + ledger: Arc, + ledger_dir: std::path::PathBuf, + signal_root: std::path::PathBuf, +} + +async fn spawn_merged_server() -> Harness { + // 1. temp roots (pid + nanos, per pane_ledger_restore.rs:13-24) + // 2. claude spec = common::sleeper_cli_spec("claude") (already has + // create_session_args, stays Running) + // 3. Build WsState exactly as spawn_server_with_ledger does + // (common/mod.rs:424-500) so WsState.identity / WsState.pane_ledger + // are OUR instances. + // 4. Freshagent state: FreshAgentState::new(shared token, shared + // broadcast tx) + // .with_terminal_registry(registry.clone()) + // .with_session_identity(Arc::new(identity.clone())) + // .with_pane_identity_binder(Arc::new( + // freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new( + // identity.clone(), Arc::clone(&ledger)))) + // (mirror rest_ws_shared_gate.rs:31-96 for the merge + listener). + // 5. Router: freshell_ws router .merge(freshell_freshagent::router(..)), + // TcpListener 127.0.0.1:0, axum::serve in tokio::spawn. + // 6. connect_and_capture_inventory(&ws_url). + // + // PORT (not new design): the body of this fn is rest_ws_shared_gate.rs's + // spawn helper (:31-96) with common::spawn_server_with_ledger's + // WsState-with-real-ledger construction (common/mod.rs:424-500) folded + // in, returning the extra handles named in `Harness`. Copy those two + // sources; do not invent new wiring. +} + +async fn rest_create_claude(h: &Harness) -> (String, String) { + // PORT: rest_ws_shared_gate.rs's raw HTTP POST helper, verbatim — + // POST /api/tabs {"mode":"claude","cwd":} with x-auth-token. + // Returns (terminal_id, session_id) read from the response's + // paneContent.sessionRef, panicking with the full body on any miss. +} + +/// 4a — a REST-created claude pane has a USABLE RESUME IDENTITY that does +/// not depend on any signal file existing: preallocated id in the registry +/// row, identity row, durable Bound ledger binding, sessionRef in the +/// create response — with the signal directory EMPTY throughout. +#[tokio::test(flavor = "multi_thread")] +async fn rest_created_claude_pane_has_durable_resume_identity_without_signals() { + let h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + uuid::Uuid::parse_str(&sid).expect("canonical UUID"); + + // "even if every signal file is destroyed by an external actor": + // there are zero signal files — identity must already be complete. + assert_eq!(std::fs::read_dir(&h.signal_root).unwrap().count(), 0); + + // identity row (A13 arm 1 + signal-drain prerequisite) + let row = h.ws_state.identity.get(&tid).expect("identity row exists at create"); + assert_eq!(row.provider.as_deref(), Some("claude")); + assert_eq!(row.session_id.as_deref(), Some(sid.as_str())); + + // registry row (GET /api/terminals rung 0) + let reg = h.registry.identity_probe_rows().into_iter() + .find(|r| r.terminal_id == tid).expect("registry row"); + assert_eq!(reg.resume_session_id.as_deref(), Some(sid.as_str())); + + // durable ledger binding, and it survives a "restart" (fresh PaneLedger + // over the same dir re-reads disk — the pane_ledger_restore.rs idiom). + let binding = h.ledger.load_binding("claude", &sid).expect("Bound row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid.as_str())); + let reread = freshell_ws::pane_ledger::PaneLedger::new(Some(h.ledger_dir.clone())); + assert!(reread.load_binding("claude", &sid).is_some(), "binding durable across restart"); + + h.registry.kill(&tid); +} + +/// 4b — A13: a WS resume (terminal.create restore:true + wire sessionRef) +/// of a session that is LIVE inside a REST-created pane is REFUSED loudly. +/// This is the exact drill violation: two live claude CLIs on one session id. +#[tokio::test(flavor = "multi_thread")] +async fn ws_resume_of_session_live_in_rest_pane_is_refused_a13() { + let mut h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + + h.ws.send(WsMessage::Text(json!({ + "type": "terminal.create", + "requestId": "req-a13-rest-live-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + "restore": true, + "sessionRef": { "provider": "claude", "sessionId": sid }, + }).to_string())).await.unwrap(); + + // expect_refusal_for: port verbatim from live_session_ref_guard.rs — + // panics on terminal.created for this requestId, returns the error frame. + let err = expect_refusal_for(&mut h.ws, "req-a13-rest-live-1").await; + assert_eq!(err["code"], json!("RESTORE_UNAVAILABLE"), "exact wire code: {err}"); + assert!(err["message"].as_str().unwrap().contains(&sid), + "message names the live session: {err}"); + + // no duplicate spawn: the REST pane is still the only claude terminal + let rows = h.registry.identity_probe_rows(); + assert_eq!(rows.len(), 1, "no second claude CLI on session {sid}: {rows:?}"); + assert_eq!(rows[0].terminal_id, tid); + + h.registry.kill(&tid); +} + +/// 4c — the SessionStart signal for a REST pane is CONSUMED (Acted), not +/// retained forever: the confirmation no-op arm requires the identity row +/// that REST creates now write. +#[tokio::test(flavor = "multi_thread")] +async fn rest_pane_session_start_signal_is_consumed_not_retained() { + let h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + + let watcher = freshell_ws::claude_signal::ClaudeSignalWatcher::new(h.signal_root.clone()); + std::fs::write( + h.signal_root.join(format!("{tid}__1.json")), + format!(r#"{{"session_id":"{sid}","source":"startup","hook_event_name":"SessionStart"}}"#), + ).expect("write signal file"); + + freshell_ws::claude_signal::drain_and_rebind_claude(&h.ws_state, &watcher).await; + tokio::task::yield_now().await; + + // Acted (same-id confirmation no-op) => file deleted. Before this fix + // the pane had no identity row => Retain forever (the drill's retained + // signal in ~/.freshell/session-signals/claude/). + assert_eq!(std::fs::read_dir(&h.signal_root).unwrap().count(), 0, + "signal consumed, not retained"); + // identity unchanged by the confirmation + assert_eq!(h.ws_state.identity.get(&tid).unwrap().session_id.as_deref(), Some(sid.as_str())); + + h.registry.kill(&tid); +} + +/// Resume direction (Required Outcome 2): a REST claude create WITH a +/// sessionRef now writes the identity row and a durable ledger binding +/// (previously: live registry row only — died at restart). +#[tokio::test(flavor = "multi_thread")] +async fn rest_claude_resume_create_writes_identity_row_and_ledger_binding() { + let h = spawn_merged_server().await; + // Fresh REST pane mints S, then kill it so S is no longer live-owned. + let (tid1, sid) = rest_create_claude(&h).await; + h.registry.kill(&tid1); + // wait until the row leaves Running so the D7 guard admits the resume + // (poll identity_probe_rows status with a bounded deadline). + + // REST resume of S: POST /api/tabs {"mode":"claude","sessionRef":{...}}. + // rest_create_claude_with_session_ref = rest_create_claude with a + // {"sessionRef":{"provider":"claude","sessionId": sid}} field added to + // the body — same raw-POST helper, same return shape. + let (tid2, sid2) = rest_create_claude_with_session_ref(&h, &sid).await; + assert_eq!(sid2, sid); + let row = h.ws_state.identity.get(&tid2).expect("resume writes the identity row"); + assert_eq!(row.session_id.as_deref(), Some(sid.as_str())); + let binding = h.ledger.load_binding("claude", &sid).expect("resume writes the binding row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid2.as_str())); + + h.registry.kill(&tid2); +} +``` + +Fill the two `todo!()` helpers by porting the cited code (`rest_ws_shared_gate.rs:31-96` for the merged server + raw HTTP POST; `common/mod.rs:424-500` for the ledger-carrying `WsState`; `spawn_server_returning_state` in `claude_session_rebind.rs:126-199` for returning `WsState`). If `WsState.identity` / field visibilities block direct reads from an integration test, use the public probes instead: `registry.identity_probe_rows()` for the registry row and `state.identity.session_ref_for(&tid)` if exposed, or add a `#[doc(hidden)] pub` accessor — prefer the least-visibility change that unblocks the assertion. If the resume test's D7/existence rungs refuse the resume for a reason unrelated to this fix (e.g. a claude-transcript existence probe), keep the fresh-direction assertions and move the resume-direction assertion down to the Task 5 recording-binder unit test as the pin — but only after reading the refusal message and confirming it is an existence-probe refusal, not an identity regression. + +- [ ] **Step 2: Run the suite** + +Run: `cargo test -p freshell-ws --test rest_claude_identity` +Expected: all PASS. (These are green-on-arrival pins for Tasks 2+5's behavior — the value is that they fail loudly if anyone reintroduces the split. Verify each is a REAL pin by spot-reverting: `git stash` nothing — instead temporarily flip the `launch_intent` conditional in `terminal_tabs.rs` back to bare `LaunchIntent::Resume`, rerun, confirm 4a/4b/4c go red, restore.) + +- [ ] **Step 3: Reconcile `pane_ledger_restore.rs:237-302`** + +Run: `cargo test -p freshell-ws --test pane_ledger_restore` +That test pins the OLD reality ("a claude resumed via the freshagent REST … its ONLY footprint is a live registry row" — dies at restart). If it constructs the footprint by hand (direct registry writes), it still passes — update only its comment to note the REST lane now also writes ledger rows (the hand-built shape now models ledger-write *failure*, still a valid degraded case). If it drives the REST router and goes red because a binding row now exists, flip its expectations to pin the NEW reality: the REST-resumed pane's binding row exists and survives the simulated restart. +Expected after reconciliation: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/freshell-ws/tests/rest_claude_identity.rs crates/freshell-ws/tests/pane_ledger_restore.rs +git commit -m "test(ws): e2e pins — REST claude identity durability, A13 refusal of REST-live sessions, signal consumption (kata hbsa)" +``` + +--- + +### Task 7: Pin the codex/opencode REST identity lanes (Required Outcome 3) + +**Files:** +- Create: `crates/freshell-ws/tests/rest_locator_identity.rs` + +**Interfaces:** +- Consumes: the Task 6 merged-server harness (copy the spawn helper — tests/ files don't share code except `common`; extract shared pieces into `tests/common/mod.rs` ONLY if they're identical, following how other helpers landed there); codex locator harness idioms from `common::spawn_server_with_specs_activity_and_codex_locator(.., codex_sessions_root)` (`common/mod.rs:578`) and `crates/freshell-ws/tests/codex_fork_rebind.rs` (fake rollout files); opencode signal idioms from `crates/freshell-ws/src/opencode_signal.rs` (first-bind arm `:290-297`, fanout `:394-420`) and `opencode_switch_rebind.rs`. +- Produces: end-to-end pins that a REST-created codex/opencode pane ends with BOTH the identity row and a durable Bound ledger row. (The exploration found the arm and the sweep pinned separately on opposite sides of the crate boundary, but no end-to-end pin — that blind spot is exactly how the claude gap survived.) + +- [ ] **Step 1: Write the opencode pin (signal lane — fully drivable in-process)** + +```rust +/// REQ 3 pin: a fresh REST opencode pane, first-bound by its plugin signal, +/// ends with identity row + durable ledger binding (not just sessionRef +/// surface). Drives opencode's first-bind arbitration arm +/// (opencode_signal.rs:290-297) exactly as the plugin would. +#[tokio::test(flavor = "multi_thread")] +async fn rest_created_opencode_pane_binds_identity_row_and_ledger() { + let h = spawn_merged_server_with_opencode_spec().await; // sleeper spec named "opencode" + let (tid, _) = rest_create(&h, "opencode").await; // fresh: no sessionRef in response yet + + // The Task 5 binder wrote the pending marker at create (MARKER_MODES arm): + assert!(pending_marker_exists(&h, &tid), "pending marker for locator-resolved provider"); + + // Forge the plugin signal and drive the opencode drain synchronously + // (mirror opencode_switch_rebind.rs for the watcher construction, + // signal-file format, and the pub drain entry point — opencode's + // equivalents of ClaudeSignalWatcher::new / drain_and_rebind_claude). + const S: &str = "ses_rest_opencode_pin_0001"; // use a VALID opencode id shape per opencode_switch_rebind.rs + write_opencode_signal(&h, &tid, S); + drive_opencode_drain(&h).await; + + let row = h.ws_state.identity.get(&tid).expect("identity row"); + assert_eq!(row.provider.as_deref(), Some("opencode")); + assert_eq!(row.session_id.as_deref(), Some(S)); + let binding = h.ledger.load_binding("opencode", S).expect("Bound ledger row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid.as_str())); + assert!(!pending_marker_exists(&h, &tid), "resolve_pending consumed the marker"); + + h.registry.kill(&tid); +} +``` + +- [ ] **Step 2: Write the codex pin (locator lane)** + +```rust +/// REQ 3 pin: a fresh REST codex pane resolved by the codex locator sweep +/// ends with identity row + durable ledger binding. Codex identity is +/// Enter-anchored (codex_association.rs:6-11) — the test feeds the submit +/// via the REST send-keys surface, then forges the rollout file the sweep +/// correlates, mirroring codex_fork_rebind.rs / the +/// spawn_server_with_specs_activity_and_codex_locator harness (150ms sweep, +/// fake codex_sessions_root). +#[tokio::test(flavor = "multi_thread")] +async fn rest_created_codex_pane_binds_identity_row_and_ledger() { + // Same merged harness + codex locator wired the way + // spawn_server_with_specs_activity_and_codex_locator wires it, sharing + // the locator Arc into BOTH WsState and FreshAgentState (main.rs:390-411 + // is the production shape). + ... + // 1. REST create {mode:"codex"} -> pending marker exists. + // 2. POST the send-keys route with an Enter (terminal_tabs.rs:1985 + // note_submit ordering) — find the exact REST route in + // terminal_tabs::maybe_send_keys's callers. + // 3. Forge the rollout file under codex_sessions_root with thread id T + // (copy the fixture format from codex_fork_rebind.rs). + // 4. Await the sweep (bounded poll, <=5s) until identity.get(tid) is Some. + // 5. Assert identity row {provider:"codex", session_id:T} and + // ledger.load_binding("codex", T) Bound with live_terminal_id == tid, + // and the pending marker consumed. +} +``` + +If wiring the codex locator into the merged REST+WS harness proves disproportionate (the locator sweep task, activity hub, and sessions root are heavy), the fallback pin — still end-to-end across the boundary that matters — is: REST create codex pane → pending marker exists → call the resolution tail directly (`freshell_ws::codex_identity`'s apply path is crate-private, so instead call `crate::pane_ledger::ledger_resolve_identity`-equivalent via the public drain used by `pane_ledger_triggers.rs`) → identity row + Bound row + marker consumed. Choose the full-locator version if `codex_fork_rebind.rs` shows it's <100 lines of harness; otherwise take the fallback and say so in the test comment. Either way the REST-side create → marker → resolved-binding chain is pinned in one test. + +- [ ] **Step 3: Run** + +Run: `cargo test -p freshell-ws --test rest_locator_identity` +Expected: PASS. If the opencode first-bind arm refuses because the Task 5 binder's pending marker changed the arm's admission logic (it admits "no identity row + live never-bound pane" — a pending *ledger* marker doesn't touch identity rows, so it shouldn't), investigate before changing any drain logic: the drains are pinned by #573/#578 tests and must not regress. + +- [ ] **Step 4: Commit** + +```bash +git add crates/freshell-ws/tests/rest_locator_identity.rs +git commit -m "test(ws): pin REST codex/opencode create -> identity row + ledger binding end to end" +``` + +--- + +### Task 8: Full verification gates and branch push + +**Files:** none (verification + push only; fix anything that surfaces, in the task where it belongs) + +**Interfaces:** +- Consumes: everything above. +- Produces: a pushed `fix/rest-terminal-session-identity` branch, all gates green. NO PR. + +- [ ] **Step 1: Rust gates (CI-exact)** + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo clippy -p freshell-codex --features real-transport --all-targets -- -D warnings +cargo clippy -p freshell-opencode --features real-transport --all-targets -- -D warnings +cargo test --workspace +``` +Expected: all green. `cargo test --workspace` is the broad run; if unrelated pre-existing failures appear, confirm they fail identically on the base commit (`git stash && git checkout 4c04dc9c -- . && ...` is NOT needed — just run the same test in a clean second worktree of `4c04dc9c` or check CI history) and leave them; fix only what this branch broke. + +- [ ] **Step 2: Contract freeze (no WS drift — this fix is REST-side + spawn-side)** + +```bash +npm ci +npm run test:port +npm run contract:generate && git diff --exit-code -- port/contract +cargo test -p freshell-protocol --locked +``` +Expected: `git diff --exit-code` clean — zero contract changes. + +- [ ] **Step 3: Coordinated JS suite** + +```bash +FRESHELL_TEST_SUMMARY="rest terminal session identity regression (kata hbsa)" npm run check +``` +Expected: green. (Waits for the shared coordinator gate if another agent holds it — wait, never kill a foreign holder.) + +- [ ] **Step 4: Push the branch (no PR)** + +```bash +git status --short # must show ONLY the untracked docs/superpowers/plans/... file +git log --oneline origin/main..HEAD +git push -u origin fix/rest-terminal-session-identity +``` +Expected: branch pushed. STOP — do not open a PR, do not close kata hbsa (the controller does both). + +--- + +## Requirements → Task Coverage Map + +| Spec requirement | Covered by | +|---|---| +| P1 mint preallocated UUID + `--session-id` argv | Tasks 1–2 (argv asserted in Task 2 Step 1) | +| P1 pre-spawn ledger binding (PIN 2, eaa25b7d scoping) | Tasks 4–5 (write + failure-delete, same gate; ordering pinned in Task 5 Step 1) | +| P1 identity row registration | Tasks 4–5 (unit) + Task 6 4a (e2e) | +| P1 sessionRef exposed (REST response, `GET /api/terminals` rung 0, `paneContent`) | Task 2 (response + registry row = rung 0 input; `terminals.rs` needs no change) | +| P1 SessionStart signal consumed as confirmation | Task 6 4c | +| 2 split + respawn entry points | Task 3 (shared `spawn_terminal_pane`, pinned) | +| 2 other entry points audit | Exploration verdict encoded in File Structure: all four REST entry points funnel through `spawn_terminal_pane` (`terminal_tabs.rs:704`); the deferred-restore variant (`:203`) has zero Rust callers today and inherits the fix through `create_terminal_tab` when wired | +| 2 REST resume direction identity-row/ledger gap | Task 5 (register on resume) + Task 6 resume test + Task 6 Step 3 (`pane_ledger_restore.rs` reconciliation) | +| 3 codex/opencode verification + pinning tests | Task 7 (identity row + ledger + pending marker, end to end) | +| 4a resume identity survives signal destruction | Task 6 `rest_created_claude_pane_has_durable_resume_identity_without_signals` | +| 4b A13 refusal of WS resume of REST-live session | Task 6 `ws_resume_of_session_live_in_rest_pane_is_refused_a13` | +| 4c signal Acted, not retained | Task 6 `rest_pane_session_start_signal_is_consumed_not_retained` | +| 5 Node parity | "Node Parity Decision" section: conventions require Rust-only; WS contract verified frozen in Task 8 Step 2; `router.ts`-lineage comment removed in Task 2 Step 4 | +| Constraints: fmt/clippy/tests/contract/JS/push-no-PR | Task 8 | +| Constraint: never touch port 3002 / real `$HOME` | Global Constraints + every test uses constructor-injected temp dirs | From 04da6d9437aa4c538218ed585b2d66cf5d049640 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:32:36 -0700 Subject: [PATCH 03/15] docs(plan): apply load-bearing validation findings to REST session-identity plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-2 validation (12 verified, 2 falsified) of kata hbsa's plan: - A1 falsified: predicate feed must use the parsed sessionRef locator presence (JSON null is absent on both doors) — Task 2 fixed, null-mint regression pin added - A2 falsified: never-retired REST identity rows leave dead panes live-looking (session directory, rename cascade, late-signal rebind) — PaneIdentityBinder gains retire_pane_identity wired into the REST exit hook, with unit + e2e pins - A3 caveat: failure-delete must precede the AlreadyExists 409 early-return - A8: main.rs wiring corrected (PaneLedger constructed at :527 — hoist it) - A12: entry-point audit settled (tabs-sync/restore deliberately deleted, kata h9vt); coverage map updated - A5/A11 validation notes folded into Tasks 2-3 Full ledger: .the-usual-logs/rest-terminal-session-identity/load-bearing-ledger.md --- ...26-07-29-rest-terminal-session-identity.md | 151 ++++++++++++++++-- 1 file changed, 134 insertions(+), 17 deletions(-) diff --git a/docs/plans/2026-07-29-rest-terminal-session-identity.md b/docs/plans/2026-07-29-rest-terminal-session-identity.md index 8c0d6f7c4..aadc9e423 100644 --- a/docs/plans/2026-07-29-rest-terminal-session-identity.md +++ b/docs/plans/2026-07-29-rest-terminal-session-identity.md @@ -7,7 +7,7 @@ **Goal:** Claude panes created via the REST agent API (`POST /api/tabs`, `POST /api/panes/:id/split`, `POST /api/panes/:id/respawn`) acquire full session identity at create time — preallocated `--session-id` in argv, pre-spawn pane-ledger binding (PIN 2), `TerminalIdentityRegistry` row, and a real `sessionRef` on every reporting surface — exactly like the WS `terminal.create` fresh-claude path, so REST panes are resumable and visible to the A13 live-owner guard. -**Architecture:** Three moves. (1) Extract the WS fresh-claude preallocation *predicate* into `freshell-platform` (both crates already depend on it) and mint the UUID on the REST path, flipping `LaunchIntent` to `Start` so `claude --session-id ` lands in argv — this alone populates the registry row, `paneContent.sessionRef`, and `GET /api/terminals` rung 0. (2) Bridge the crate boundary for the two write-side identity homes (`TerminalIdentityRegistry` + `PaneLedger`, both `freshell-ws`-owned and unreachable from `freshell-freshagent`) with a new `PaneIdentityBinder` trait defined in `freshell-terminal` (same seam pattern as the existing read-only `SessionIdentityLookup`), implemented in `freshell-ws`, injected in `freshell-server::main`. (3) Regression tests at the merged REST+WS server level proving resume identity, A13 refusal, and SessionStart signal consumption, plus pinning tests for the codex/opencode REST lanes. +**Architecture:** Three moves. (1) Extract the WS fresh-claude preallocation *predicate* into `freshell-platform` (both crates already depend on it) and mint the UUID on the REST path, flipping `LaunchIntent` to `Start` so `claude --session-id ` lands in argv — this alone populates the registry row, `paneContent.sessionRef`, and `GET /api/terminals` rung 0. (2) Bridge the crate boundary for the two write-side identity homes (`TerminalIdentityRegistry` + `PaneLedger`, both `freshell-ws`-owned and unreachable from `freshell-freshagent`) with a new `PaneIdentityBinder` trait defined in `freshell-terminal` (same seam pattern as the existing read-only `SessionIdentityLookup`), implemented in `freshell-ws`, injected in `freshell-server::main` — carrying the create-time writes AND the exit-time retire hygiene the REST exit hook could never reach (load-bearing validation A2: un-retired rows leave dead panes live-looking to the session directory and durably rebindable by late signals). (3) Regression tests at the merged REST+WS server level proving resume identity, A13 refusal, and SessionStart signal consumption, plus pinning tests for the codex/opencode REST lanes. **Tech Stack:** Rust (toolchain pinned 1.96.0), axum, tokio, `async-trait`, `uuid`; existing test harnesses in `crates/freshell-ws/tests/common/mod.rs` and `crates/freshell-freshagent/src/terminal_tabs.rs mod tests`. @@ -46,11 +46,11 @@ What Requirement 5 therefore reduces to in this plan: (a) zero `server/` changes |---|---|---| | `crates/freshell-platform/src/cli_launch.rs` | Modify | New shared pure predicate `should_preallocate_fresh_claude(..)` + unit tests (truth table). Single source of the "fresh claude" policy for both doors. | | `crates/freshell-ws/src/terminal.rs` | Modify | `handle_create` adopts the shared predicate (behavior-preserving swap of `terminal.rs:1630-1637`). | -| `crates/freshell-terminal/src/registry.rs` | Modify | New `PaneIdentityBinder` trait next to `SessionIdentityLookup` (`registry.rs:638-641`) — the write-side seam. | +| `crates/freshell-terminal/src/registry.rs` | Modify | New `PaneIdentityBinder` trait next to `SessionIdentityLookup` (`registry.rs:638-641`) — the write-side + exit-retire seam. | | `crates/freshell-terminal/Cargo.toml` | Modify | Add `async-trait` dependency (workspace version). | | `crates/freshell-ws/src/pane_identity_binder.rs` | Create | `LedgerPaneIdentityBinder` — the production impl over `TerminalIdentityRegistry` + `Arc`, mirroring `terminal.rs:2211-2236`, `:2281-2292`, `:2487-2538`. In-module unit tests with tempdir ledger. | | `crates/freshell-ws/src/lib.rs` | Modify | `pub mod pane_identity_binder;` | -| `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify | UUID mint after `derive_resume_identity` (`:767`); `claude_fresh_prealloc` threading through `GatedSettleInputs`; `LaunchIntent` conditional (`:1335`); binder call sites (pre-spawn, failure-delete, post-spawn); `tab_create_missing_session_identity` condition update (`:1792-1805`); unit tests in `mod tests`. | +| `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify | UUID mint after `derive_resume_identity` (`:767`); `claude_fresh_prealloc` threading through `GatedSettleInputs`; `LaunchIntent` conditional (`:1335`); binder call sites (pre-spawn, failure-delete, post-spawn, exit-retire); `tab_create_missing_session_identity` condition update (`:1792-1805`); unit tests in `mod tests`. | | `crates/freshell-freshagent/src/lib.rs` | Modify | `FreshAgentState.pane_identity: Option>` + `with_pane_identity_binder(..)` builder (mirror `with_session_identity`, `lib.rs:455-461`). | | `crates/freshell-server/src/main.rs` | Modify | Wire `LedgerPaneIdentityBinder` into the freshagent state (next to the `with_session_identity` wiring at `main.rs:286`). | | `crates/freshell-ws/tests/rest_claude_identity.rs` | Create | End-to-end merged REST+WS server tests: resume identity survives signal destruction (4a), A13 refusal of WS resume of a REST-live session (4b), SessionStart signal consumed Acted (4c), REST resume-direction ledger/identity writes. | @@ -270,7 +270,8 @@ Expected: FAIL — `sessionRef` absent from `paneContent` (today's behavior: no In `crates/freshell-freshagent/src/terminal_tabs.rs`, immediately after the `derive_resume_identity` call at `:767`: ```rust - let (mut resume_session_id, accepted_session_ref) = derive_resume_identity(body, &mode)?; + let (mut resume_session_id, accepted_session_ref, session_ref_locator_present) = + derive_resume_identity(body, &mode)?; // Fresh-claude preallocation (kata hbsa): WS parity. The WS door's // fresh-claude special case (freshell-ws/src/terminal.rs, LIVE-PATH LAW @@ -286,7 +287,7 @@ In `crates/freshell-freshagent/src/terminal_tabs.rs`, immediately after the `der let claude_fresh_prealloc = freshell_platform::should_preallocate_fresh_claude( &mode, body.get("restore").and_then(serde_json::Value::as_bool), - body.get("sessionRef").is_some(), + session_ref_locator_present, resume_session_id.as_deref(), ); if claude_fresh_prealloc { @@ -296,7 +297,18 @@ In `crates/freshell-freshagent/src/terminal_tabs.rs`, immediately after the `der (`uuid::Uuid::new_v4()` is already in use in this file at `:831` for the amplifier mint — same import path.) -Note the predicate uses `body.get("sessionRef").is_some()` — the raw-field check, exact WS semantics (`create.session_ref.is_none()`), not `accepted_session_ref.is_some()`: a wire `sessionRef` of ANY provider disables the mint on both doors. +Note the predicate's `has_session_ref` argument: it MUST be the PARSED locator presence, not the raw `body.get("sessionRef").is_some()`. The load-bearing validation (ledger A1) falsified the raw-field check: WS deserializes `"sessionRef": null` to `None` (serde `Option` semantics, `client_messages.rs:233-234`) and MINTS, while the raw check sees `Some(Value::Null)` and skips the mint — recreating the identity-less pane on a shape BOTH doors accept. Change `derive_resume_identity` (`terminal_tabs.rs:491-510`) to also return the pre-provider-filter parse result (`session_ref_locator_present: bool` = the `:495-498` `serde_json::from_value::` parse succeeded) and feed THAT. It matches WS on every mutually-accepted shape (absent / null / well-formed locator of ANY provider — any parsed locator disables the mint, matching `create.session_ref.is_none()`), and it matches `derive_resume_identity`'s own view of malformed locator objects (treated as absent → mint fires; WS would silently drop the whole frame, `ws terminal.rs:495-519` accept-and-strip — an acceptance-domain asymmetry accepted and recorded in the load-bearing ledger, not fixable by any predicate input). Do NOT use `accepted_session_ref.is_some()` (provider-filtered — would mint under a non-claude sessionRef, unlike WS). Add this REST-side regression pin next to the Step 1 test: + +```rust +#[tokio::test] +async fn create_fresh_claude_tab_with_null_session_ref_still_mints() { + // Ledger A1 regression: `"sessionRef": null` is ABSENT on both doors. + // Same harness and assertions as + // create_fresh_claude_tab_preallocates_session_identity, with + // `"sessionRef": serde_json::Value::Null` added to the POST body — + // the response must still carry a minted claude sessionRef. +} +``` Placement matters: this sits BEFORE the amplifier block (`:794-950`) and BEFORE the D7 guard (`:961-990`). The D7/D8 machinery keys off `accepted_session_ref`, which is `None` here, so a minted id claims no lease and trips no guard — matching the WS doctrine that fresh preallocs never route through D8. @@ -356,7 +368,7 @@ The warn at `:1792-1805` fires when the create *request* carried neither `sessio { ``` -(If `payload` in that function does not embed `paneContent`, gate on the spawn result's sessionRef instead — the `TerminalSpawnResult`/local variable that carried `paneContent` into the payload a few lines earlier in `create_terminal_tab`. The semantic is fixed: *skip the warn when the finished create has a sessionRef*.) +(If `payload` in that function does not embed `paneContent`, gate on the spawn result's sessionRef instead — the `TerminalSpawnResult`/local variable that carried `paneContent` into the payload a few lines earlier in `create_terminal_tab`. The semantic is fixed: *skip the warn when the finished create has a sessionRef*. Validation note, ledger A11: the payload copy at `:1780-1782` may already carry `paneContent.sessionRef` post-mint, in which case the warn goes quiet without this edit — keep the edit as an explicit guard, but don't be surprised if the warn-site test already passes before it.) Then fix the tests: - If `create_fresh_session_provider_tab_without_identity_warns_invariant` used `mode:"claude"`, switch it to a session-provider mode that still has no create-time identity source (`"gemini"`) — the alarm is still correct for those. @@ -473,7 +485,7 @@ async fn respawn_pane_claude_ends_with_session_identity() { - [ ] **Step 2: Run them** Run: `cargo test -p freshell-freshagent -- split_pane_claude respawn_pane_claude` -Expected: PASS (Task 2 fixed the shared pipeline). If respawn FAILS because `respawn_pane` rebuilds a body that carries the old pane's `sessionRef`/`resumeSessionId` and the prior session is refused by a guard, that's a real finding: fix by letting the respawn body flow through `derive_resume_identity` + the Task 2 mint unchanged (a respawn body WITH identity resumes, one WITHOUT mints) and record the behavior in the test's comment. +Expected: PASS (Task 2 fixed the shared pipeline). Validated (ledger A5): `split_pane` and `respawn_pane` forward the client body untouched (`pane_ops.rs:99-156`, `:696-742` — respawn never kills the old pane, it orphans it), and a minted id leaves `accepted_session_ref = None`, so the D7 guard is skipped and no self-409 is possible. One intentional behavior to leave alone: a client-SUPPLIED live `sessionRef` in a respawn body DOES 409 (`terminal_tabs.rs:963-966`, "No self-exemption for respawn") — that is deliberate D7 policy; do not pin the opposite. - [ ] **Step 3: Commit** @@ -526,6 +538,18 @@ pub trait PaneIdentityBinder: Send + Sync { cwd: Option<&str>, create_request_id: Option<&str>, ); + /// Exit-side hygiene (load-bearing ledger A2): mirrors the WS kill path + /// (terminal.rs:1334-1342, :3860-3868, :3896) — retire the identity row + /// (in-memory flag flip), delete any pending marker, retire closed + /// ledger bindings for this terminal. Without it, dead REST panes stay + /// live-looking: the session directory lists them as running + /// (session_directory.rs:716-766), the reverse rename cascade fires on + /// them (sessions.rs:167-187), and a late new-id SessionStart skips the + /// `current.retired -> Acted` no-op arm and durably rebinds a dead pane + /// (claude_signal.rs:253-342). Idempotent; harmless no-op for terminals + /// with no identity row. Called from the pane exit hook for ALL + /// non-shell creates. + async fn retire_pane_identity(&self, terminal_id: &str); } ``` @@ -614,6 +638,28 @@ mod tests { // identity row still lands even when durability is degraded: assert!(identity.get("t-x").is_some()); } + + #[tokio::test] + async fn retire_pane_identity_retires_row_and_clears_pending() { + use freshell_terminal::registry::PaneIdentityBinder as _; + // Ledger A2: exit-side hygiene — retired rows must stop looking live. + let (b, _ledger, identity, dir) = binder("retire"); + b.register_create_identity("t-rest-4", "claude", Some(SID), Some("/tmp"), None).await; + b.retire_pane_identity("t-rest-4").await; + // Retired == invisible to live lookups, exactly what the WS kill path + // produces (match the accessor the identity.rs retire tests use — + // e.g. the live find_by_session no longer returns the terminal, + // while the retired-inclusive lookup still does). + assert!(identity.find_by_session("claude", SID).is_none(), + "retired row is not a live owner"); + // And the pending-marker delete arm: register a marker-mode pane, + // retire it, assert its pending/.json is gone (same + // marker-read idiom as the markers test above). + b.register_create_identity("t-codex-r", "codex", None, Some("/tmp"), None).await; + b.retire_pane_identity("t-codex-r").await; + // assert pending marker absent for "t-codex-r" + let _ = std::fs::remove_dir_all(&dir); + } } ``` @@ -771,6 +817,16 @@ impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinde // terminal.rs if it is private today. } } + + async fn retire_pane_identity(&self, terminal_id: &str) { + // PORT (not new design): the WS kill-path hygiene — identity retire + // (terminal.rs:1334 / :3896), pending-marker delete (:1342 / :3866), + // and retire_closed for the terminal's ledger rows (:3860-3868) — + // substituting self.identity / self.ledger and warn_write_failure, + // ledger I/O wrapped in spawn_blocking like the methods above. The + // identity retire is an in-memory flag flip; this method changes NO + // drain logic (the #573/#578-pinned drains stay untouched). + } } ``` @@ -781,7 +837,7 @@ The two `...port from terminal.rs...` spots are literal ports: open `crates/fres - [ ] **Step 4: Run the tests to verify they pass** Run: `cargo test -p freshell-ws pane_identity_binder` -Expected: PASS (4 tests). +Expected: PASS (5 tests). - [ ] **Step 5: Full-crate checks for the touched crates** @@ -803,13 +859,13 @@ git commit -m "feat(ws,terminal): PaneIdentityBinder seam — write-side identit **Files:** - Modify: `crates/freshell-freshagent/src/lib.rs` (state field + builder) -- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` (`GatedSettleInputs`, three call sites in `settle_gated_create`, `mod tests`) +- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` (`GatedSettleInputs`, four call sites in `settle_gated_create` — pre-spawn, failure-delete, post-spawn, exit-retire — `mod tests`) - Modify: `crates/freshell-freshagent/Cargo.toml` (add `async-trait` if not already a direct dep) - Modify: `crates/freshell-server/src/main.rs` (wiring) **Interfaces:** - Consumes: `freshell_terminal::registry::PaneIdentityBinder` (Task 4), `GatedSettleInputs.claude_fresh_prealloc` (Task 2), `freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder` (Task 4, wiring only — freshagent code references only the trait). -- Produces: `FreshAgentState::with_pane_identity_binder(self, binder: Arc) -> Self`; call ordering inside `settle_gated_create`: `record_prespawn_claude_binding` (fresh preallocs only) → spawn → on failure `delete_prespawn_claude_binding` (same gate) / on success `register_create_identity` (all creates). Task 6's e2e tests and the production wiring rely on this ordering. +- Produces: `FreshAgentState::with_pane_identity_binder(self, binder: Arc) -> Self`; call ordering inside `settle_gated_create`: `record_prespawn_claude_binding` (fresh preallocs only) → spawn → on failure `delete_prespawn_claude_binding` (same gate) / on success `register_create_identity` (all creates); and exit-side `retire_pane_identity` from the pane exit hook (all non-shell creates, ledger A2). Task 6's e2e tests and the production wiring rely on this ordering. - [ ] **Step 1: Write the failing unit tests with a recording fake binder** @@ -846,6 +902,9 @@ impl freshell_terminal::registry::PaneIdentityBinder for RecordingBinder { "register:{terminal_id}:{mode}:{}", resume_session_id.unwrap_or("-") )); } + async fn retire_pane_identity(&self, terminal_id: &str) { + self.events.lock().unwrap().push(format!("retire:{terminal_id}")); + } } #[tokio::test] @@ -925,11 +984,34 @@ async fn failed_fresh_claude_spawn_deletes_its_prespawn_binding() { "failure-delete for the minted id: {events:?}"); assert!(!events.iter().any(|e| e.starts_with("register:")), "{events:?}"); } + +#[tokio::test] +async fn rest_pane_exit_retires_identity_via_binder() { + // Ledger A2: dead REST panes must not keep live-looking identity rows. + let binder = std::sync::Arc::new(RecordingBinder::default()); + let (state, registry, _capture) = state_with_claude_capture_spec(); + let state = state.with_pane_identity_binder(binder.clone()); + + let (status, body) = post(app(state), "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + let tid = body["data"]["paneContent"]["terminalId"].as_str().unwrap().to_string(); + + registry.kill(&tid); + // The exit hook runs asynchronously — poll with a bounded deadline. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if binder.events().contains(&format!("retire:{tid}")) { break; } + assert!(std::time::Instant::now() < deadline, + "exit hook never retired the pane: {:?}", binder.events()); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} ``` - [ ] **Step 2: Run to verify they fail** -Run: `cargo test -p freshell-freshagent -- drives_binder registers_identity_without_prespawn deletes_its_prespawn` +Run: `cargo test -p freshell-freshagent -- drives_binder registers_identity_without_prespawn deletes_its_prespawn exit_retires_identity` Expected: FAIL to compile — `with_pane_identity_binder` doesn't exist. - [ ] **Step 3: Implement state field + builder** @@ -957,7 +1039,7 @@ initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder } ``` -- [ ] **Step 4: Implement the three call sites in `settle_gated_create`** +- [ ] **Step 4: Implement the four call sites in `settle_gated_create`** 1. Add `pane_identity: Option>` to `GatedSettleInputs` (`:1130`), populate from `state.pane_identity.clone()` at the `tokio::spawn` site (`:1096`), destructure at `:1168`. @@ -990,7 +1072,7 @@ initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder (Use the locally destructured variable names — `terminal_id`/`cwd`/`create_request_id` exist under whatever names `GatedSettleInputs` destructures them to at `:1168`; keep the argument *meanings* fixed.) -3. **Spawn-failure arm** (inside the error branch of the `create_result` match after `:1481-1493`, before the error is returned): +3. **Spawn-failure arm** — inside the error branch of the `create_result` match, **at the TOP of the branch, before the AlreadyExists 409 early-return at `:1525` and before the "ORDER IS LOAD-BEARING" cleanup cluster (`:1534+`)**. The load-bearing validation (ledger A3) proved this error branch is the only exit between the pre-spawn write and spawn success — but it contains TWO returns, so the delete MUST precede the first: ```rust // PIN 2 compensating delete — SAME gate as the write (eaa25b7d). @@ -1026,14 +1108,16 @@ initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder } ``` +5. **Exit hook** — the pane exit hook built in `settle_gated_create` (~`:1396-1463`; its KNOWN GAP comment at `:1401-1409` documents that it cannot call `identity.retire` across the crate boundary — the binder is exactly that bridge). Capture `pane_identity` and the terminal id into the hook and call `binder.retire_pane_identity(&terminal_id).await` when the pane exits, for ALL non-shell creates (idempotent; no-op for panes without identity rows). Rewrite the stale KNOWN GAP comment to describe the binder seam. Validated stakes (ledger A2): without this, the session directory lists dead REST panes as running (`session_directory.rs:716-766`), the rename cascade persists `titleOverride` for dead terminals (`sessions.rs:167-187`), and a late new-id SessionStart durably rebinds a dead pane (`claude_signal.rs:253-342`). + - [ ] **Step 5: Run the tests to verify they pass** -Run: `cargo test -p freshell-freshagent -- drives_binder registers_identity_without_prespawn deletes_its_prespawn` +Run: `cargo test -p freshell-freshagent -- drives_binder registers_identity_without_prespawn deletes_its_prespawn exit_retires_identity` Expected: PASS. Then `cargo test -p freshell-freshagent` — full crate PASS. - [ ] **Step 6: Wire production in `freshell-server::main`** -In `crates/freshell-server/src/main.rs`, find where the freshagent state is built with `.with_session_identity(..)` (`main.rs:286`) and where the WS `TerminalIdentityRegistry` and `Arc` locals are constructed (they're the same values passed into `WsState`). Chain: +In `crates/freshell-server/src/main.rs`: the freshagent builder chain runs at `main.rs:255-286` (`.with_session_identity(..)` at `:286`, `terminal_identity` already in scope at `:283`), but `pane_ledger` is constructed LATER, at `:527` (validated, ledger A8). Prefer HOISTING the `PaneLedger` construction above the `FreshAgentState` build — it depends only on `home` (resolved ~`:144`), so the hoist is mechanical — and keep the builder-chain wiring. (Alternative: the `set_identity_sink` post-construction-setter precedent at `:531-542`; if taken, the setter must run before the state's consumers at `:956`/`:1017`, and note `FreshOpencodeState::new(fresh_agent_state.clone())` at `:260` will not see a plain field set after it — acceptable for the REST-only scope, but the hoist avoids the question entirely.) Chain: ```rust .with_pane_identity_binder(std::sync::Arc::new( @@ -1226,6 +1310,38 @@ async fn rest_pane_session_start_signal_is_consumed_not_retained() { h.registry.kill(&tid); } +/// Ledger A2 regression: REST pane EXIT retires identity. Without retire, a +/// dead REST pane stays live-looking (session directory `is_running: true`, +/// session_directory.rs:716-766) and a late SessionStart with a NEW id skips +/// the `current.retired -> Acted` arm and durably rebinds the dead pane +/// (claude_signal.rs:253-342). +#[tokio::test(flavor = "multi_thread")] +async fn dead_rest_pane_is_retired_and_late_signal_does_not_rebind_it() { + let h = spawn_merged_server().await; + let (tid, _sid) = rest_create_claude(&h).await; + + h.registry.kill(&tid); + // The exit hook drives binder.retire_pane_identity asynchronously: poll + // (bounded, <=5s) until the identity row for `tid` reports retired (use + // the same retired-row probe the WS kill-path tests use). + + // Late signal carrying a NEW session id for the dead pane: + let watcher = freshell_ws::claude_signal::ClaudeSignalWatcher::new(h.signal_root.clone()); + const NEW_SID: &str = "29a53649-9999-4888-8777-666655554444"; + std::fs::write( + h.signal_root.join(format!("{tid}__2.json")), + format!(r#"{{"session_id":"{NEW_SID}","source":"startup","hook_event_name":"SessionStart"}}"#), + ).expect("write signal file"); + freshell_ws::claude_signal::drain_and_rebind_claude(&h.ws_state, &watcher).await; + + // Retired no-op arm: signal consumed; NO rebind of the dead pane, NO + // durable ledger row naming the dead terminal. + assert_eq!(std::fs::read_dir(&h.signal_root).unwrap().count(), 0, + "signal consumed via the retired arm, not retained"); + assert!(h.ledger.load_binding("claude", NEW_SID).is_none(), + "no durable binding to a dead terminal id"); +} + /// Resume direction (Required Outcome 2): a REST claude create WITH a /// sessionRef now writes the identity row and a durable ledger binding /// (previously: live registry row only — died at restart). @@ -1421,8 +1537,9 @@ Expected: branch pushed. STOP — do not open a PR, do not close kata hbsa (the | P1 sessionRef exposed (REST response, `GET /api/terminals` rung 0, `paneContent`) | Task 2 (response + registry row = rung 0 input; `terminals.rs` needs no change) | | P1 SessionStart signal consumed as confirmation | Task 6 4c | | 2 split + respawn entry points | Task 3 (shared `spawn_terminal_pane`, pinned) | -| 2 other entry points audit | Exploration verdict encoded in File Structure: all four REST entry points funnel through `spawn_terminal_pane` (`terminal_tabs.rs:704`); the deferred-restore variant (`:203`) has zero Rust callers today and inherits the fix through `create_terminal_tab` when wired | +| 2 other entry points audit | Settled by load-bearing validation (ledger A12): exactly THREE live REST spawn routes (tabs, split, respawn), all funnel through `spawn_terminal_pane` (`terminal_tabs.rs:704`); `POST /api/tabs-sync/restore` was deliberately deleted (docs/plans/2026-07-26-recover-my-panes.md Task 9, kata h9vt) — the `:193-195` comment is stale; the deferred-restore variant (`:203`) has zero callers and inherits the fix if revived; the WS auto-resume respawn door (`terminal.rs:2945`) is identity-preserving by reuse and out of scope | | 2 REST resume direction identity-row/ledger gap | Task 5 (register on resume) + Task 6 resume test + Task 6 Step 3 (`pane_ledger_restore.rs` reconciliation) | +| Ledger A2 (validation finding): dead REST panes must not look live | Tasks 4–5 (`retire_pane_identity` + exit hook) + Task 5 exit-retire unit pin + Task 6 dead-pane retire e2e pin | | 3 codex/opencode verification + pinning tests | Task 7 (identity row + ledger + pending marker, end to end) | | 4a resume identity survives signal destruction | Task 6 `rest_created_claude_pane_has_durable_resume_identity_without_signals` | | 4b A13 refusal of WS resume of REST-live session | Task 6 `ws_resume_of_session_live_in_rest_pane_is_refused_a13` | From 6d7e5a228e4d45bde525d80fd107373123a1351f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:01:11 -0700 Subject: [PATCH 04/15] =?UTF-8?q?docs(plan):=20fix=20fresheyes=20blocking?= =?UTF-8?q?=20issues=20=E2=80=94=20sync=20binder=20trait,=20no=20async-tra?= =?UTF-8?q?it=20dep,=20tracked=20superpowers=20plan=20premise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-eyes review iteration 1 found three blocking defects in the plan: 1. The plan claimed docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md was untracked and Task 8's final gate expected git status to show it untracked — but that file is committed on this branch (eb28b3dc). Rewrote the Global Constraint and the Task 8 Step 4 gate to match the actual repo state (status must be empty). 2. Task 4 instructed copying an async-trait dependency line from freshell-freshagent/Cargo.toml — no crate in the workspace depends on async-trait (the house pattern is boxed-future aliases). Removed the dependency entirely. 3. Task 5 Step 4.5 instructed .await on retire_pane_identity inside the REST exit hook — ExitHook is a sync FnOnce on the PTY reader OS thread with no tokio runtime, so that step could not compile/run as written. Joint fix for 2+3: PaneIdentityBinder is now a fully synchronous trait (the SessionIdentityLookup precedent — every underlying registry/ledger operation is sync). Async REST call sites hop ledger-touching calls through awaited tokio::task::spawn_blocking (the WS create path's own idiom, terminal.rs:2211-2234), preserving PIN 2 ordering; the exit hook calls retire_pane_identity inline sync, exactly mirroring the WS exit hook (terminal.rs:1334-1342). Updated trait/impl/test snippets, fake binder, call-site snippets, file tables, and commit staging lists accordingly (no Cargo.toml/Cargo.lock changes remain in the plan). --- ...26-07-29-rest-terminal-session-identity.md | 281 ++++++++++-------- 1 file changed, 152 insertions(+), 129 deletions(-) diff --git a/docs/plans/2026-07-29-rest-terminal-session-identity.md b/docs/plans/2026-07-29-rest-terminal-session-identity.md index aadc9e423..b619492fa 100644 --- a/docs/plans/2026-07-29-rest-terminal-session-identity.md +++ b/docs/plans/2026-07-29-rest-terminal-session-identity.md @@ -9,7 +9,7 @@ **Architecture:** Three moves. (1) Extract the WS fresh-claude preallocation *predicate* into `freshell-platform` (both crates already depend on it) and mint the UUID on the REST path, flipping `LaunchIntent` to `Start` so `claude --session-id ` lands in argv — this alone populates the registry row, `paneContent.sessionRef`, and `GET /api/terminals` rung 0. (2) Bridge the crate boundary for the two write-side identity homes (`TerminalIdentityRegistry` + `PaneLedger`, both `freshell-ws`-owned and unreachable from `freshell-freshagent`) with a new `PaneIdentityBinder` trait defined in `freshell-terminal` (same seam pattern as the existing read-only `SessionIdentityLookup`), implemented in `freshell-ws`, injected in `freshell-server::main` — carrying the create-time writes AND the exit-time retire hygiene the REST exit hook could never reach (load-bearing validation A2: un-retired rows leave dead panes live-looking to the session directory and durably rebindable by late signals). (3) Regression tests at the merged REST+WS server level proving resume identity, A13 refusal, and SessionStart signal consumption, plus pinning tests for the codex/opencode REST lanes. -**Tech Stack:** Rust (toolchain pinned 1.96.0), axum, tokio, `async-trait`, `uuid`; existing test harnesses in `crates/freshell-ws/tests/common/mod.rs` and `crates/freshell-freshagent/src/terminal_tabs.rs mod tests`. +**Tech Stack:** Rust (toolchain pinned 1.96.0), axum, tokio, `uuid` — no new dependencies (the binder trait is synchronous, matching the `SessionIdentityLookup` precedent; the codebase deliberately declines `async-trait`, see `identity_sink.rs:37`); existing test harnesses in `crates/freshell-ws/tests/common/mod.rs` and `crates/freshell-freshagent/src/terminal_tabs.rs mod tests`. **Baseline:** branch `fix/rest-terminal-session-identity` in worktree `/home/dan/code/freshell/.worktrees/rest-terminal-session-identity`, branched from `origin/main` @ `4c04dc9c`. All file:line references below are against that commit. @@ -27,7 +27,7 @@ - Kata: tracked as kata issue hbsa. Do NOT close it from within this workflow; the controller closes it after the branch lands. - Do NOT create a PR — stop after pushing the branch. - README.md is the only end-user markdown doc; this plan under `docs/plans/` is a working/agent doc. Create no other markdown files. -- There is a pre-existing **untracked** file `docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md` in the worktree from a different (codex-scoped) workflow. Leave it untracked; never `git add -A` — stage files explicitly in every commit. +- The file `docs/superpowers/plans/2026-07-29-rest-terminal-session-identity.md` is a **different (codex-scoped) workflow's plan that is already committed on this branch** (commit `eb28b3dc`, "docs: plan REST Codex terminal identity publication"). It is out of scope here: do not modify, delete, or implement from it. Never `git add -A` — stage files explicitly in every commit. ## Node Parity Decision (Required Outcome 5 — resolved, no Node code changes) @@ -46,8 +46,7 @@ What Requirement 5 therefore reduces to in this plan: (a) zero `server/` changes |---|---|---| | `crates/freshell-platform/src/cli_launch.rs` | Modify | New shared pure predicate `should_preallocate_fresh_claude(..)` + unit tests (truth table). Single source of the "fresh claude" policy for both doors. | | `crates/freshell-ws/src/terminal.rs` | Modify | `handle_create` adopts the shared predicate (behavior-preserving swap of `terminal.rs:1630-1637`). | -| `crates/freshell-terminal/src/registry.rs` | Modify | New `PaneIdentityBinder` trait next to `SessionIdentityLookup` (`registry.rs:638-641`) — the write-side + exit-retire seam. | -| `crates/freshell-terminal/Cargo.toml` | Modify | Add `async-trait` dependency (workspace version). | +| `crates/freshell-terminal/src/registry.rs` | Modify | New `PaneIdentityBinder` trait next to `SessionIdentityLookup` (`registry.rs:638-641`) — the write-side + exit-retire seam. **Synchronous** (like `SessionIdentityLookup`): `freshell-terminal` is deliberately tokio-free (`pty.rs:38-44`) and the exit hook that must call it is a sync `FnOnce` on the PTY reader thread. No new dependencies. | | `crates/freshell-ws/src/pane_identity_binder.rs` | Create | `LedgerPaneIdentityBinder` — the production impl over `TerminalIdentityRegistry` + `Arc`, mirroring `terminal.rs:2211-2236`, `:2281-2292`, `:2487-2538`. In-module unit tests with tempdir ledger. | | `crates/freshell-ws/src/lib.rs` | Modify | `pub mod pane_identity_binder;` | | `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify | UUID mint after `derive_resume_identity` (`:767`); `claude_fresh_prealloc` threading through `GatedSettleInputs`; `LaunchIntent` conditional (`:1335`); binder call sites (pre-spawn, failure-delete, post-spawn, exit-retire); `tab_create_missing_session_identity` condition update (`:1792-1805`); unit tests in `mod tests`. | @@ -499,8 +498,7 @@ git commit -m "test(freshagent): pin claude identity mint on REST split and resp ### Task 4: `PaneIdentityBinder` seam + `LedgerPaneIdentityBinder` **Files:** -- Modify: `crates/freshell-terminal/src/registry.rs` (trait, next to `SessionIdentityLookup` at `:638-641`) -- Modify: `crates/freshell-terminal/Cargo.toml` (add `async-trait` — use the workspace-dep form other crates use, e.g. copy the line from `crates/freshell-freshagent/Cargo.toml`) +- Modify: `crates/freshell-terminal/src/registry.rs` (trait, next to `SessionIdentityLookup` at `:638-641`; NO Cargo.toml change — the trait is synchronous, and the workspace deliberately has zero `async-trait` dependents: the house pattern where async is unavoidable is a boxed-future alias, see `identity_sink.rs:37-39`, `serve.rs:43-44`; here async is avoidable because every underlying operation is sync) - Create: `crates/freshell-ws/src/pane_identity_binder.rs` - Modify: `crates/freshell-ws/src/lib.rs` (`pub mod pane_identity_binder;`) @@ -508,14 +506,26 @@ git commit -m "test(freshagent): pin claude identity mint on REST split and resp - Consumes: `crate::identity::TerminalIdentityRegistry` (`identity.rs:33-56`, methods `upsert`), `crate::pane_ledger::{PaneLedger, BindingWrite}` (`pane_ledger.rs:144-152`, `:358`), the `MARKER_MODES` list + `record_pending` call currently at `terminal.rs:2523-2540`, and `freshell-ws`'s existing `now_ms()` helper (grep `fn now_ms` in the crate; reuse, don't redefine). - Produces (Task 5 and Task 6 depend on these exact signatures): +The trait is **fully synchronous** — a deliberate, load-bearing choice, not a style preference: +every underlying operation is sync (`TerminalIdentityRegistry` is an `Arc>` with plain +`fn` methods, `identity.rs:65/:95/:111/:160`; every `PaneLedger` writer is a plain +`fn -> std::io::Result<()>` behind a `std::sync::Mutex`, `pane_ledger.rs:358/:597/:727`), and the +one caller that CANNOT be async is the pane exit hook: `freshell_terminal::pty::ExitHook` is +`Box` (`pty.rs:55`) invoked on the plain OS reader thread +(`pty.rs:485-507`) where there is no tokio runtime (`freshell-terminal` is deliberately +tokio-free, `pty.rs:38-44`) — an `async fn retire` would be uncallable there. This exactly +mirrors the WS twin: its exit hook does the retire + pending-delete **inline sync** +(`terminal.rs:1334-1342`, the self-described "one truly-synchronous ledger call site"). Async +REST call sites hop ledger-touching calls through `tokio::task::spawn_blocking` (the WS create +path's own idiom, `terminal.rs:2211-2234`) — see Task 5. + ```rust // crates/freshell-terminal/src/registry.rs -#[async_trait::async_trait] -pub trait PaneIdentityBinder: Send + Sync { +pub trait PaneIdentityBinder: Send + Sync + std::fmt::Debug { /// PIN 2 durability-before-argv: durable claude binding row written /// BEFORE the spawn makes the preallocated id observable. Callers gate /// this on their fresh-prealloc flag ONLY (eaa25b7d). - async fn record_prespawn_claude_binding( + fn record_prespawn_claude_binding( &self, session_id: &str, terminal_id: &str, @@ -525,12 +535,12 @@ pub trait PaneIdentityBinder: Send + Sync { ); /// Compensating delete when the spawn that minted the id fails. /// MUST be gated on the SAME predicate as the record (eaa25b7d). - async fn delete_prespawn_claude_binding(&self, session_id: &str); + fn delete_prespawn_claude_binding(&self, session_id: &str); /// Post-spawn identity registration, mirroring the WS post-spawn block /// (freshell-ws/src/terminal.rs): identity row + durable binding for any /// non-shell create with a session id; pending marker for the /// locator-resolved providers (codex/opencode/amplifier) without one. - async fn register_create_identity( + fn register_create_identity( &self, terminal_id: &str, mode: &str, @@ -548,11 +558,16 @@ pub trait PaneIdentityBinder: Send + Sync { /// `current.retired -> Acted` no-op arm and durably rebinds a dead pane /// (claude_signal.rs:253-342). Idempotent; harmless no-op for terminals /// with no identity row. Called from the pane exit hook for ALL - /// non-shell creates. - async fn retire_pane_identity(&self, terminal_id: &str); + /// non-shell creates. SYNC ON PURPOSE: the exit hook is a plain FnOnce + /// on the PTY reader thread — blocking IO is safe there, .await is + /// impossible (mirrors the WS exit hook, terminal.rs:1334-1342). + fn retire_pane_identity(&self, terminal_id: &str); } ``` +(The `std::fmt::Debug` supertrait matches `SessionIdentityLookup` at `registry.rs:638-641`, which +carries it because these objects land in `Debug`-derived state.) + and `freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new(identity: TerminalIdentityRegistry, ledger: Arc) -> LedgerPaneIdentityBinder` implementing it. - [ ] **Step 1: Write the failing unit tests for the impl** @@ -584,25 +599,25 @@ mod tests { const SID: &str = "29a53649-1111-4222-8333-444455556666"; - #[tokio::test] - async fn prespawn_binding_writes_a_bound_claude_row_and_delete_removes_it() { + #[test] + fn prespawn_binding_writes_a_bound_claude_row_and_delete_removes_it() { use freshell_terminal::registry::PaneIdentityBinder as _; let (b, ledger, _identity, dir) = binder("prespawn"); - b.record_prespawn_claude_binding(SID, "t-rest-1", "claude", Some("/tmp"), Some("req-1")).await; + b.record_prespawn_claude_binding(SID, "t-rest-1", "claude", Some("/tmp"), Some("req-1")); let row = ledger.load_binding("claude", SID).expect("pre-spawn row exists (PIN 2)"); assert_eq!(row.live_terminal_id.as_deref(), Some("t-rest-1")); assert_eq!(row.state, RowState::Bound); - b.delete_prespawn_claude_binding(SID).await; + b.delete_prespawn_claude_binding(SID); assert!(ledger.load_binding("claude", SID).is_none(), "failure-delete removes the row"); let _ = std::fs::remove_dir_all(&dir); } - #[tokio::test] - async fn register_create_identity_writes_identity_row_and_binding() { + #[test] + fn register_create_identity_writes_identity_row_and_binding() { use freshell_terminal::registry::PaneIdentityBinder as _; let (b, ledger, identity, dir) = binder("register"); - b.register_create_identity("t-rest-2", "claude", Some(SID), Some("/tmp"), Some("req-2")).await; + b.register_create_identity("t-rest-2", "claude", Some(SID), Some("/tmp"), Some("req-2")); let row = identity.get("t-rest-2").expect("identity row (the A13/signal-drain prerequisite)"); assert_eq!(row.provider.as_deref(), Some("claude")); assert_eq!(row.session_id.as_deref(), Some(SID)); @@ -611,41 +626,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[tokio::test] - async fn register_create_identity_skips_shell_and_marks_pending_for_marker_modes() { + #[test] + fn register_create_identity_skips_shell_and_marks_pending_for_marker_modes() { use freshell_terminal::registry::PaneIdentityBinder as _; let (b, ledger, identity, dir) = binder("markers"); // shell: nothing at all - b.register_create_identity("t-shell", "shell", None, None, None).await; + b.register_create_identity("t-shell", "shell", None, None, None); assert!(identity.get("t-shell").is_none()); // codex without an id: pending marker (locator lane resolves later), // exactly the WS MARKER_MODES arm (terminal.rs:2523-2540). - b.register_create_identity("t-codex", "codex", None, Some("/tmp"), Some("req-3")).await; + b.register_create_identity("t-codex", "codex", None, Some("/tmp"), Some("req-3")); assert!(identity.get("t-codex").is_none(), "no premature identity row"); assert!(ledger.has_pending("t-codex"), "pending marker written"); // use the ledger's actual pending-read API — see pane_ledger.rs `pending/` store and how terminal.rs/pane_ledger_tests assert markers; adjust the accessor name to match. let _ = std::fs::remove_dir_all(&dir); } - #[tokio::test] - async fn ledger_write_failure_never_panics_the_create() { + #[test] + fn ledger_write_failure_never_panics_the_create() { use freshell_terminal::registry::PaneIdentityBinder as _; // A disabled ledger (or an unwritable root) must degrade to a warn, // never an Err/panic — failure never blocks the create. let identity = crate::identity::TerminalIdentityRegistry::default(); let b = LedgerPaneIdentityBinder::new(identity.clone(), Arc::new(PaneLedger::disabled())); - b.record_prespawn_claude_binding(SID, "t-x", "claude", None, None).await; - b.register_create_identity("t-x", "claude", Some(SID), None, None).await; + b.record_prespawn_claude_binding(SID, "t-x", "claude", None, None); + b.register_create_identity("t-x", "claude", Some(SID), None, None); // identity row still lands even when durability is degraded: assert!(identity.get("t-x").is_some()); } - #[tokio::test] - async fn retire_pane_identity_retires_row_and_clears_pending() { + #[test] + fn retire_pane_identity_retires_row_and_clears_pending() { use freshell_terminal::registry::PaneIdentityBinder as _; // Ledger A2: exit-side hygiene — retired rows must stop looking live. + // Sync test on purpose: retire MUST be callable with no runtime, + // because production calls it from the PTY reader thread's exit hook. let (b, _ledger, identity, dir) = binder("retire"); - b.register_create_identity("t-rest-4", "claude", Some(SID), Some("/tmp"), None).await; - b.retire_pane_identity("t-rest-4").await; + b.register_create_identity("t-rest-4", "claude", Some(SID), Some("/tmp"), None); + b.retire_pane_identity("t-rest-4"); // Retired == invisible to live lookups, exactly what the WS kill path // produces (match the accessor the identity.rs retire tests use — // e.g. the live find_by_session no longer returns the terminal, @@ -655,8 +672,8 @@ mod tests { // And the pending-marker delete arm: register a marker-mode pane, // retire it, assert its pending/.json is gone (same // marker-read idiom as the markers test above). - b.register_create_identity("t-codex-r", "codex", None, Some("/tmp"), None).await; - b.retire_pane_identity("t-codex-r").await; + b.register_create_identity("t-codex-r", "codex", None, Some("/tmp"), None); + b.retire_pane_identity("t-codex-r"); // assert pending marker absent for "t-codex-r" let _ = std::fs::remove_dir_all(&dir); } @@ -672,9 +689,8 @@ Expected: FAIL to compile — trait and struct don't exist. - [ ] **Step 3: Implement trait + impl** -1. `crates/freshell-terminal/Cargo.toml`: add `async-trait` (same form as `freshell-freshagent`'s dependency on it). -2. `crates/freshell-terminal/src/registry.rs`: add the trait exactly as specified in **Interfaces** above, directly below `SessionIdentityLookup` (`:638-641`), with a doc comment naming both consumers (REST spawn pipeline) and the producer (`freshell-ws`). -3. `crates/freshell-ws/src/pane_identity_binder.rs`: +1. `crates/freshell-terminal/src/registry.rs`: add the trait exactly as specified in **Interfaces** above, directly below `SessionIdentityLookup` (`:638-641`), with a doc comment naming both consumers (REST spawn pipeline) and the producer (`freshell-ws`). NO dependency changes — the trait is sync, so `freshell-terminal/Cargo.toml` is untouched. +2. `crates/freshell-ws/src/pane_identity_binder.rs`: ```rust //! Write-side pane-identity seam for the REST spawn pipeline (kata hbsa). @@ -721,9 +737,15 @@ impl LedgerPaneIdentityBinder { } } -#[async_trait::async_trait] +// The binder itself is plain sync: every PaneLedger writer is a sync +// `fn -> io::Result<()>` (pane_ledger.rs:358/:597/:727) and the identity +// registry is a sync RwLock (identity.rs). Async REST call sites hop the +// ledger-touching calls through spawn_blocking (Task 5), mirroring the WS +// create path's own idiom (terminal.rs:2211-2234); the exit hook calls +// retire_pane_identity inline on the PTY reader thread, mirroring the WS +// exit hook's inline-sync retire (terminal.rs:1334-1342). impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinder { - async fn record_prespawn_claude_binding( + fn record_prespawn_claude_binding( &self, session_id: &str, terminal_id: &str, @@ -731,41 +753,26 @@ impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinde cwd: Option<&str>, create_request_id: Option<&str>, ) { - let ledger = Arc::clone(&self.ledger); - let (sid, tid, m) = (session_id.to_string(), terminal_id.to_string(), mode.to_string()); - let (c, rid) = (cwd.map(str::to_string), create_request_id.map(str::to_string)); - let now = crate::now_ms(); // reuse the crate's existing helper; adjust path to where it lives - let tid_for_log = tid.clone(); - let result = tokio::task::spawn_blocking(move || { - ledger.record_binding(&BindingWrite { - provider: "claude", - session_id: &sid, - terminal_id: &tid, - mode: &m, - cwd: c.as_deref(), - create_request_id: rid.as_deref(), - now_ms: now, - }) - }) - .await - .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); - if let Err(err) = result { - Self::warn_write_failure(&tid_for_log, "pre-spawn claude binding (PIN 2)", &err); + if let Err(err) = self.ledger.record_binding(&BindingWrite { + provider: "claude", + session_id, + terminal_id, + mode, + cwd, + create_request_id, + now_ms: crate::now_ms(), // reuse the crate's existing helper; adjust path to where it lives + }) { + Self::warn_write_failure(terminal_id, "pre-spawn claude binding (PIN 2)", &err); } } - async fn delete_prespawn_claude_binding(&self, session_id: &str) { - let ledger = Arc::clone(&self.ledger); - let sid = session_id.to_string(); - let result = tokio::task::spawn_blocking(move || ledger.delete_binding("claude", &sid)) - .await - .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); - if let Err(err) = result { + fn delete_prespawn_claude_binding(&self, session_id: &str) { + if let Err(err) = self.ledger.delete_binding("claude", session_id) { Self::warn_write_failure("(spawn-failed)", "pre-spawn binding failure-delete", &err); } } - async fn register_create_identity( + fn register_create_identity( &self, terminal_id: &str, mode: &str, @@ -779,60 +786,58 @@ impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinde // terminal.rs:2453-2540 (terminal_meta_record_for_create semantics, // identity.upsert, record_binding, MARKER_MODES/record_pending), // substituting self.identity / self.ledger and the warn above for - // surface_write_failure. Export MARKER_MODES from terminal.rs - // (`pub(crate) const` -> keep crate-visible and reference it here) - // rather than duplicating the list. + // surface_write_failure, and dropping the spawn_blocking wrappers + // (the async hop lives at the Task 5 call sites, not here). Export + // MARKER_MODES from terminal.rs (`pub(crate) const` -> keep + // crate-visible and reference it here) rather than duplicating the + // list. if mode == "shell" { return; } if let Some(session_id) = resume_session_id.filter(|s| !s.is_empty()) { self.identity.upsert(terminal_id, Some(mode), Some(session_id), cwd, crate::now_ms()); - let ledger = Arc::clone(&self.ledger); - let (sid, tid, m) = (session_id.to_string(), terminal_id.to_string(), mode.to_string()); - let (c, rid) = (cwd.map(str::to_string), create_request_id.map(str::to_string)); - let now = crate::now_ms(); - let tid_for_log = tid.clone(); - let result = tokio::task::spawn_blocking(move || { - ledger.record_binding(&BindingWrite { - provider: &m.clone(), - session_id: &sid, - terminal_id: &tid, - mode: &m, - cwd: c.as_deref(), - create_request_id: rid.as_deref(), - now_ms: now, - }) - }) - .await - .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); - if let Err(err) = result { - Self::warn_write_failure(&tid_for_log, "post-spawn identity binding", &err); + if let Err(err) = self.ledger.record_binding(&BindingWrite { + provider: mode, // keep exactly what the WS block does (provider = mode) + session_id, + terminal_id, + mode, + cwd, + create_request_id, + now_ms: crate::now_ms(), + }) { + Self::warn_write_failure(terminal_id, "post-spawn identity binding", &err); } } else if crate::terminal::MARKER_MODES.contains(&mode) { // PORT (not new design): the pending-marker arm — copy the exact // record_pending call from terminal.rs's MARKER_MODES block - // (:2523-2540): same arguments, wrapped in spawn_blocking, with - // the warn_write_failure policy above replacing + // (:2523-2540): same arguments, called directly (sync), with the + // warn_write_failure policy above replacing // surface_write_failure. Make MARKER_MODES `pub(crate)` in // terminal.rs if it is private today. } } - async fn retire_pane_identity(&self, terminal_id: &str) { + fn retire_pane_identity(&self, terminal_id: &str) { // PORT (not new design): the WS kill-path hygiene — identity retire // (terminal.rs:1334 / :3896), pending-marker delete (:1342 / :3866), // and retire_closed for the terminal's ledger rows (:3860-3868) — // substituting self.identity / self.ledger and warn_write_failure, - // ledger I/O wrapped in spawn_blocking like the methods above. The - // identity retire is an in-memory flag flip; this method changes NO - // drain logic (the #573/#578-pinned drains stay untouched). + // all called directly (sync). This method MUST stay runtime-free: + // production calls it from the PTY reader thread's exit hook, where + // blocking IO is safe and tokio does not exist. The identity retire + // is an in-memory flag flip; this method changes NO drain logic + // (the #573/#578-pinned drains stay untouched). } } ``` +Also give `LedgerPaneIdentityBinder` a `Debug` impl to satisfy the trait's supertrait — a manual +one-liner (`impl std::fmt::Debug for LedgerPaneIdentityBinder { .. write "LedgerPaneIdentityBinder" .. }`) +is fine if `PaneLedger`/`TerminalIdentityRegistry` don't derive it. + The two `...port from terminal.rs...` spots are literal ports: open `crates/freshell-ws/src/terminal.rs:2453-2540`, copy the bodies, substitute `self.identity`/`self.ledger`, and adjust visibility of `MARKER_MODES` (make it `pub(crate)` if it isn't) and `now_ms` (reuse wherever it's defined in the crate). Do NOT re-derive the provider-vs-mode choice — keep exactly what the WS block does (`provider = mode` on the post-spawn write). -4. `crates/freshell-ws/src/lib.rs`: `pub mod pane_identity_binder;` +3. `crates/freshell-ws/src/lib.rs`: `pub mod pane_identity_binder;` - [ ] **Step 4: Run the tests to verify they pass** @@ -847,9 +852,9 @@ Expected: PASS, no warnings. - [ ] **Step 6: Commit** ```bash -git add crates/freshell-terminal/Cargo.toml crates/freshell-terminal/src/registry.rs \ +git add crates/freshell-terminal/src/registry.rs \ crates/freshell-ws/src/pane_identity_binder.rs crates/freshell-ws/src/lib.rs \ - crates/freshell-ws/src/terminal.rs Cargo.lock + crates/freshell-ws/src/terminal.rs git commit -m "feat(ws,terminal): PaneIdentityBinder seam — write-side identity/ledger bridge for the REST lane" ``` @@ -860,9 +865,10 @@ git commit -m "feat(ws,terminal): PaneIdentityBinder seam — write-side identit **Files:** - Modify: `crates/freshell-freshagent/src/lib.rs` (state field + builder) - Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` (`GatedSettleInputs`, four call sites in `settle_gated_create` — pre-spawn, failure-delete, post-spawn, exit-retire — `mod tests`) -- Modify: `crates/freshell-freshagent/Cargo.toml` (add `async-trait` if not already a direct dep) - Modify: `crates/freshell-server/src/main.rs` (wiring) +(No Cargo.toml changes: the binder trait is sync and lives in `freshell-terminal`, which `freshell-freshagent` already depends on.) + **Interfaces:** - Consumes: `freshell_terminal::registry::PaneIdentityBinder` (Task 4), `GatedSettleInputs.claude_fresh_prealloc` (Task 2), `freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder` (Task 4, wiring only — freshagent code references only the trait). - Produces: `FreshAgentState::with_pane_identity_binder(self, binder: Arc) -> Self`; call ordering inside `settle_gated_create`: `record_prespawn_claude_binding` (fresh preallocs only) → spawn → on failure `delete_prespawn_claude_binding` (same gate) / on success `register_create_identity` (all creates); and exit-side `retire_pane_identity` from the pane exit hook (all non-shell creates, ledger A2). Task 6's e2e tests and the production wiring rely on this ordering. @@ -872,7 +878,7 @@ git commit -m "feat(ws,terminal): PaneIdentityBinder seam — write-side identit In `crates/freshell-freshagent/src/terminal_tabs.rs` `mod tests`: ```rust -#[derive(Default)] +#[derive(Default, Debug)] struct RecordingBinder { events: std::sync::Mutex>, } @@ -883,18 +889,17 @@ impl RecordingBinder { } } -#[async_trait::async_trait] impl freshell_terminal::registry::PaneIdentityBinder for RecordingBinder { - async fn record_prespawn_claude_binding( + fn record_prespawn_claude_binding( &self, session_id: &str, terminal_id: &str, _mode: &str, _cwd: Option<&str>, _create_request_id: Option<&str>, ) { self.events.lock().unwrap().push(format!("prespawn:{terminal_id}:{session_id}")); } - async fn delete_prespawn_claude_binding(&self, session_id: &str) { + fn delete_prespawn_claude_binding(&self, session_id: &str) { self.events.lock().unwrap().push(format!("delete:{session_id}")); } - async fn register_create_identity( + fn register_create_identity( &self, terminal_id: &str, mode: &str, resume_session_id: Option<&str>, _cwd: Option<&str>, _create_request_id: Option<&str>, ) { @@ -902,7 +907,7 @@ impl freshell_terminal::registry::PaneIdentityBinder for RecordingBinder { "register:{terminal_id}:{mode}:{}", resume_session_id.unwrap_or("-") )); } - async fn retire_pane_identity(&self, terminal_id: &str) { + fn retire_pane_identity(&self, terminal_id: &str) { self.events.lock().unwrap().push(format!("retire:{terminal_id}")); } } @@ -1057,20 +1062,28 @@ initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder if let (Some(binder), Some(session_id)) = (pane_identity.as_ref(), resume_session_id.as_deref()) { - binder - .record_prespawn_claude_binding( - session_id, - &terminal_id, - &mode, - cwd.as_deref(), - Some(&create_request_id), - ) - .await; + // Binder methods are sync (blocking fsync IO inside) — hop + // through spawn_blocking, the WS create path's own idiom + // (terminal.rs:2211-2234). Awaited: PIN 2 requires the + // durable row to exist BEFORE the spawn below. + let binder = std::sync::Arc::clone(binder); + let (sid, tid, m) = (session_id.to_string(), terminal_id.clone(), mode.clone()); + let (c, rid) = (cwd.clone(), create_request_id.clone()); + let _ = tokio::task::spawn_blocking(move || { + binder.record_prespawn_claude_binding( + &sid, + &tid, + &m, + c.as_deref(), + Some(&rid), + ); + }) + .await; // JoinError only — write failures are warned inside the binder } } ``` -(Use the locally destructured variable names — `terminal_id`/`cwd`/`create_request_id` exist under whatever names `GatedSettleInputs` destructures them to at `:1168`; keep the argument *meanings* fixed.) +(Use the locally destructured variable names — `terminal_id`/`cwd`/`create_request_id` exist under whatever names `GatedSettleInputs` destructures them to at `:1168`, and adjust the `.clone()`s to their actual types — e.g. if `create_request_id` is already `String`, `Some(&rid)` as shown; keep the argument *meanings* fixed.) 3. **Spawn-failure arm** — inside the error branch of the `create_result` match, **at the TOP of the branch, before the AlreadyExists 409 early-return at `:1525` and before the "ORDER IS LOAD-BEARING" cleanup cluster (`:1534+`)**. The load-bearing validation (ledger A3) proved this error branch is the only exit between the pre-spawn write and spawn success — but it contains TWO returns, so the delete MUST precede the first: @@ -1080,7 +1093,12 @@ initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder if let (Some(binder), Some(session_id)) = (pane_identity.as_ref(), resume_session_id.as_deref()) { - binder.delete_prespawn_claude_binding(session_id).await; + let binder = std::sync::Arc::clone(binder); + let sid = session_id.to_string(); + let _ = tokio::task::spawn_blocking(move || { + binder.delete_prespawn_claude_binding(&sid); + }) + .await; } } ``` @@ -1096,19 +1114,24 @@ initialize `pane_identity: None` in `FreshAgentState::new`, and add the builder // guard's identity arm and the SessionStart signal drain acting // (claude_signal.rs retains signals for identity-less panes forever). if let Some(binder) = pane_identity.as_ref() { - binder - .register_create_identity( - &terminal_id, - &mode, - resume_session_id.as_deref(), - cwd.as_deref(), - Some(&create_request_id), - ) - .await; + let binder = std::sync::Arc::clone(binder); + let (tid, m) = (terminal_id.clone(), mode.clone()); + let (sid, c, rid) = + (resume_session_id.clone(), cwd.clone(), create_request_id.clone()); + let _ = tokio::task::spawn_blocking(move || { + binder.register_create_identity( + &tid, + &m, + sid.as_deref(), + c.as_deref(), + Some(&rid), + ); + }) + .await; } ``` -5. **Exit hook** — the pane exit hook built in `settle_gated_create` (~`:1396-1463`; its KNOWN GAP comment at `:1401-1409` documents that it cannot call `identity.retire` across the crate boundary — the binder is exactly that bridge). Capture `pane_identity` and the terminal id into the hook and call `binder.retire_pane_identity(&terminal_id).await` when the pane exits, for ALL non-shell creates (idempotent; no-op for panes without identity rows). Rewrite the stale KNOWN GAP comment to describe the binder seam. Validated stakes (ledger A2): without this, the session directory lists dead REST panes as running (`session_directory.rs:716-766`), the rename cascade persists `titleOverride` for dead terminals (`sessions.rs:167-187`), and a late new-id SessionStart durably rebinds a dead pane (`claude_signal.rs:253-342`). +5. **Exit hook** — the pane exit hook built in `settle_gated_create` (~`:1396-1463`; its KNOWN GAP comment at `:1401-1409` documents that it cannot call `identity.retire` across the crate boundary — the binder is exactly that bridge). Capture an owned `Option>` clone and an owned terminal-id `String` into the hook closure and call `binder.retire_pane_identity(&terminal_id)` — a PLAIN SYNC CALL, no `.await`, no `tokio::spawn` — when the pane exits, for ALL non-shell creates (idempotent; no-op for panes without identity rows). This is why the trait is sync: the exit hook is `Box` (`pty.rs:55`) invoked on the PTY reader OS thread (`pty.rs:485-507`) with NO tokio runtime (`Handle::current()` would panic there); blocking IO is safe on that thread, and this exactly mirrors the WS exit hook's inline-sync retire + pending-delete (`terminal.rs:1334-1342`, "the one truly-synchronous ledger call site"). Rewrite the stale KNOWN GAP comment to describe the binder seam. Validated stakes (ledger A2): without this, the session directory lists dead REST panes as running (`session_directory.rs:716-766`), the rename cascade persists `titleOverride` for dead terminals (`sessions.rs:167-187`), and a late new-id SessionStart durably rebinds a dead pane (`claude_signal.rs:253-342`). - [ ] **Step 5: Run the tests to verify they pass** @@ -1137,7 +1160,7 @@ Expected: compiles clean. ```bash git add crates/freshell-freshagent/src/lib.rs crates/freshell-freshagent/src/terminal_tabs.rs \ - crates/freshell-freshagent/Cargo.toml crates/freshell-server/src/main.rs Cargo.lock + crates/freshell-server/src/main.rs git commit -m "feat(freshagent): REST creates write identity rows and ledger bindings via PaneIdentityBinder (kata hbsa)" ``` @@ -1519,7 +1542,7 @@ Expected: green. (Waits for the shared coordinator gate if another agent holds i - [ ] **Step 4: Push the branch (no PR)** ```bash -git status --short # must show ONLY the untracked docs/superpowers/plans/... file +git status --short # must be EMPTY — everything is committed (the docs/superpowers/plans/... file is tracked on this branch, see Global Constraints) git log --oneline origin/main..HEAD git push -u origin fix/rest-terminal-session-identity ``` From 65cc1e76296912ad4d3ba092ff680d8e41a2f34b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:25:53 -0700 Subject: [PATCH 05/15] =?UTF-8?q?docs(plan):=20fix=20fresheyes=20blocking?= =?UTF-8?q?=20issues=20=E2=80=94=20assert=20sessionRef=20on=20real=20surfa?= =?UTF-8?q?ces=20(broadcast=20paneContent=20+=20registry=20rows,=20not=20R?= =?UTF-8?q?EST=20HTTP=20bodies);=20spot-revert=20the=20mint=20gate,=20not?= =?UTF-8?q?=20launch=5Fintent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-07-29-rest-terminal-session-identity.md | 95 ++++++++++++------- 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/docs/plans/2026-07-29-rest-terminal-session-identity.md b/docs/plans/2026-07-29-rest-terminal-session-identity.md index b619492fa..3607252bb 100644 --- a/docs/plans/2026-07-29-rest-terminal-session-identity.md +++ b/docs/plans/2026-07-29-rest-terminal-session-identity.md @@ -7,7 +7,7 @@ **Goal:** Claude panes created via the REST agent API (`POST /api/tabs`, `POST /api/panes/:id/split`, `POST /api/panes/:id/respawn`) acquire full session identity at create time — preallocated `--session-id` in argv, pre-spawn pane-ledger binding (PIN 2), `TerminalIdentityRegistry` row, and a real `sessionRef` on every reporting surface — exactly like the WS `terminal.create` fresh-claude path, so REST panes are resumable and visible to the A13 live-owner guard. -**Architecture:** Three moves. (1) Extract the WS fresh-claude preallocation *predicate* into `freshell-platform` (both crates already depend on it) and mint the UUID on the REST path, flipping `LaunchIntent` to `Start` so `claude --session-id ` lands in argv — this alone populates the registry row, `paneContent.sessionRef`, and `GET /api/terminals` rung 0. (2) Bridge the crate boundary for the two write-side identity homes (`TerminalIdentityRegistry` + `PaneLedger`, both `freshell-ws`-owned and unreachable from `freshell-freshagent`) with a new `PaneIdentityBinder` trait defined in `freshell-terminal` (same seam pattern as the existing read-only `SessionIdentityLookup`), implemented in `freshell-ws`, injected in `freshell-server::main` — carrying the create-time writes AND the exit-time retire hygiene the REST exit hook could never reach (load-bearing validation A2: un-retired rows leave dead panes live-looking to the session directory and durably rebindable by late signals). (3) Regression tests at the merged REST+WS server level proving resume identity, A13 refusal, and SessionStart signal consumption, plus pinning tests for the codex/opencode REST lanes. +**Architecture:** Three moves. (1) Extract the WS fresh-claude preallocation *predicate* into `freshell-platform` (both crates already depend on it) and mint the UUID on the REST path, flipping `LaunchIntent` to `Start` so `claude --session-id ` lands in argv — this alone populates the registry row, `paneContent.sessionRef` on the broadcast `ui.command` frame (the REST HTTP bodies carry only ids — `{tabId, paneId, terminalId}` — and are deliberately unchanged), and `GET /api/terminals` rung 0. (2) Bridge the crate boundary for the two write-side identity homes (`TerminalIdentityRegistry` + `PaneLedger`, both `freshell-ws`-owned and unreachable from `freshell-freshagent`) with a new `PaneIdentityBinder` trait defined in `freshell-terminal` (same seam pattern as the existing read-only `SessionIdentityLookup`), implemented in `freshell-ws`, injected in `freshell-server::main` — carrying the create-time writes AND the exit-time retire hygiene the REST exit hook could never reach (load-bearing validation A2: un-retired rows leave dead panes live-looking to the session directory and durably rebindable by late signals). (3) Regression tests at the merged REST+WS server level proving resume identity, A13 refusal, and SessionStart signal consumption, plus pinning tests for the codex/opencode REST lanes. **Tech Stack:** Rust (toolchain pinned 1.96.0), axum, tokio, `uuid` — no new dependencies (the binder trait is synchronous, matching the `SessionIdentityLookup` precedent; the codebase deliberately declines `async-trait`, see `identity_sink.rs:37`); existing test harnesses in `crates/freshell-ws/tests/common/mod.rs` and `crates/freshell-freshagent/src/terminal_tabs.rs mod tests`. @@ -210,8 +210,17 @@ In `crates/freshell-freshagent/src/terminal_tabs.rs` `mod tests` (starts `:2190` async fn create_fresh_claude_tab_preallocates_session_identity() { // kata hbsa P1: REST parity with the WS fresh-claude special case. // A fresh POST /api/tabs {mode:"claude"} must mint a --session-id, - // carry it in the registry row, and expose it as paneContent.sessionRef. + // carry it in the registry row, and expose it as paneContent.sessionRef + // on the broadcast `ui.command` frame. NOTE the surfaces: the REST HTTP + // body carries ONLY {tabId, paneId, terminalId} (terminal_tabs.rs: + // 1828-1832) — paneContent (and its sessionRef) travels on the broadcast + // frame, because the REST route always calls with broadcast=true + // (terminal_tabs.rs:196-197). let (state, registry, argv_capture_path) = state_with_claude_capture_spec(); // build per :3321's harness + // Subscribe BEFORE the POST, exactly the way the sibling test at :3383 + // captures its ui.command frames off the state's broadcast channel — + // reuse that subscription + frame-reading code verbatim. + let mut frames = subscribe_broadcast_frames(&state); // per :3383's capture idiom let (status, body) = post( app(state), "/api/tabs", @@ -224,16 +233,19 @@ async fn create_fresh_claude_tab_preallocates_session_identity() { .await; assert_eq!(status, axum::http::StatusCode::OK, "create failed: {body}"); - // 1. sessionRef surfaced on the create response (paneContent promotion). - let session_ref = body["data"]["paneContent"]["sessionRef"].clone(); - assert_eq!(session_ref["provider"], serde_json::json!("claude"), "sessionRef: {body}"); + // 1. sessionRef surfaced on the broadcast paneContent (the create-time + // reporting surface — the HTTP body has NO paneContent). Read the + // ui.command frame the same way the :3383 sibling does. + let pane_content = next_ui_command_pane_content(&mut frames).await; // per :3383's frame-reading idiom + let session_ref = pane_content["sessionRef"].clone(); + assert_eq!(session_ref["provider"], serde_json::json!("claude"), "sessionRef: {pane_content}"); let sid = session_ref["sessionId"].as_str().expect("sessionId string").to_string(); uuid::Uuid::parse_str(&sid).expect("preallocated id is a canonical UUID"); // 2. Registry row carries the id (this is GET /api/terminals rung 0, // terminals.rs:686-698 — populating it makes sessionRef real there // with zero changes to terminals.rs). - let terminal_id = body["data"]["paneContent"]["terminalId"] + let terminal_id = body["data"]["terminalId"] .as_str() .expect("terminalId") .to_string(); @@ -256,13 +268,13 @@ async fn create_fresh_claude_tab_preallocates_session_identity() { ``` Notes for the implementer: -- If the response JSON shape differs (e.g. `paneContent` nested differently), print `body` from the failing run and adjust the *accessor paths only* — the three assertions (response sessionRef, registry row, argv pair) are the contract. +- The REST HTTP body never carries `paneContent` — it is `{tabId, paneId, terminalId}` only (`terminal_tabs.rs:1828-1832`). The sessionRef surface at create time is the broadcast `ui.command` frame (the REST route always passes `broadcast=true`, `terminal_tabs.rs:196-197`); capture it exactly the way the sibling test at `:3383` does. If the captured frame's shape differs from the sketch, print the frame and adjust the accessor paths *within the frame* — the three assertions (broadcast `paneContent.sessionRef`, registry row, argv pair) are the contract. - If no argv-capture helper exists locally in this `mod tests`, port `write_fake_claude_capture()` from `crates/freshell-ws/tests/claude_session_rebind.rs:53-79` (a `#!/bin/sh` script dumping `"$@"` to `$CLAUDE_ARGV_CAPTURE_PATH` then `exec sleep 300`, chmod 0755, written to `std::env::temp_dir()`), and point the spec's `default_cmd` at it directly (avoid `env_var`/process-global env in this shared test binary if a direct path works). - [ ] **Step 2: Run the test to verify it fails** Run: `cargo test -p freshell-freshagent create_fresh_claude_tab_preallocates_session_identity -- --exact` -Expected: FAIL — `sessionRef` absent from `paneContent` (today's behavior: no mint, `resume_session_id = None`). +Expected: FAIL — `sessionRef` absent from the broadcast frame's `paneContent` (today's behavior: no mint, `resume_session_id = None`). - [ ] **Step 3: Implement the mint in `spawn_terminal_pane`** @@ -305,7 +317,7 @@ async fn create_fresh_claude_tab_with_null_session_ref_still_mints() { // Same harness and assertions as // create_fresh_claude_tab_preallocates_session_identity, with // `"sessionRef": serde_json::Value::Null` added to the POST body — - // the response must still carry a minted claude sessionRef. + // the broadcast paneContent must still carry a minted claude sessionRef. } ``` @@ -412,7 +424,7 @@ git commit -m "fix(freshagent): REST claude creates mint a preallocated --sessio - [ ] **Step 1: Write the failing-or-passing pin tests** -Same harness as Task 2's test (fake claude spec with `create_session_args`; the split/respawn spawns will each need their own argv-capture file if argv is asserted — asserting `sessionRef` + registry row is sufficient here and avoids capture-path races): +Same harness as Task 2's test (fake claude spec with `create_session_args`; the split/respawn spawns will each need their own argv-capture file if argv is asserted — asserting the registry rows is sufficient here and avoids both capture-path races and broadcast-frame plumbing; remember the REST bodies carry only `{tabId, paneId, terminalId}` / `{paneId, terminalId}` / `{terminalId}`, so identity is read from the registry): ```rust #[tokio::test] @@ -426,18 +438,25 @@ async fn split_pane_claude_preallocates_fresh_session_identity() { let (status, tab) = post(router.clone(), "/api/tabs", serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; assert_eq!(status, axum::http::StatusCode::OK); - let pane_id = tab["data"]["paneContent"]["paneId"].as_str() - .or_else(|| tab["data"]["paneId"].as_str()) + // The /api/tabs body is {tabId, paneId, terminalId} — no paneContent. + // Identity is read from the registry rows (rung 0), keyed by terminalId. + let pane_id = tab["data"]["paneId"].as_str() .expect("pane id in create response").to_string(); - let first_sid = tab["data"]["paneContent"]["sessionRef"]["sessionId"] - .as_str().expect("first pane minted").to_string(); + let first_tid = tab["data"]["terminalId"].as_str() + .expect("terminal id in create response").to_string(); + let first_sid = registry.identity_probe_rows().into_iter() + .find(|r| r.terminal_id == first_tid).expect("create registry row") + .resume_session_id.expect("first pane minted"); let (status, split) = post(router, &format!("/api/panes/{pane_id}/split"), serde_json::json!({"mode":"claude"}), true).await; assert_eq!(status, axum::http::StatusCode::OK, "split failed: {split}"); - let split_ref = split["data"]["paneContent"]["sessionRef"].clone(); - assert_eq!(split_ref["provider"], serde_json::json!("claude"), "split sessionRef: {split}"); - let split_sid = split_ref["sessionId"].as_str().expect("split minted").to_string(); + // The split body is {paneId, terminalId} — again, identity via registry. + let split_tid = split["data"]["terminalId"].as_str() + .expect("terminal id in split response").to_string(); + let split_sid = registry.identity_probe_rows().into_iter() + .find(|r| r.terminal_id == split_tid).expect("split registry row") + .resume_session_id.expect("split minted"); uuid::Uuid::parse_str(&split_sid).expect("canonical UUID"); assert_ne!(split_sid, first_sid, "split must mint its OWN identity"); @@ -464,22 +483,25 @@ async fn respawn_pane_claude_ends_with_session_identity() { let (status, tab) = post(router.clone(), "/api/tabs", serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; assert_eq!(status, axum::http::StatusCode::OK); - let pane_id = tab["data"]["paneContent"]["paneId"].as_str() - .or_else(|| tab["data"]["paneId"].as_str()) + let pane_id = tab["data"]["paneId"].as_str() .expect("pane id").to_string(); let (status, respawned) = post(router, &format!("/api/panes/{pane_id}/respawn"), serde_json::json!({}), true).await; assert_eq!(status, axum::http::StatusCode::OK, "respawn failed: {respawned}"); - let sid = respawned["data"]["paneContent"]["sessionRef"]["sessionId"] - .as_str().expect("respawned pane has identity").to_string(); + // The respawn body is {terminalId} only — identity via the registry row. + let respawn_tid = respawned["data"]["terminalId"].as_str() + .expect("terminal id in respawn response").to_string(); + let sid = registry.identity_probe_rows().into_iter() + .find(|r| r.terminal_id == respawn_tid).expect("respawn registry row") + .resume_session_id.expect("respawned pane has identity"); uuid::Uuid::parse_str(&sid).expect("canonical UUID"); for r in registry.identity_probe_rows() { registry.kill(&r.terminal_id); } } ``` -(Adjust response accessor paths against actual bodies as in Task 2; look at the existing split/respawn tests in `pane_ops.rs`/`terminal_tabs.rs mod tests` for the exact request/response shapes — `pane_ops.rs:1011` shows a helper POSTing `/api/tabs` you can crib.) +(The REST bodies carry only ids — the identity assertions read the registry rows, never the HTTP body; look at the existing split/respawn tests in `pane_ops.rs`/`terminal_tabs.rs mod tests` for the exact request/response shapes — `pane_ops.rs:1011` shows a helper POSTing `/api/tabs` you can crib.) - [ ] **Step 2: Run them** @@ -923,8 +945,12 @@ async fn fresh_claude_rest_create_drives_binder_prespawn_then_register() { let (status, body) = post(app(state), "/api/tabs", serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; assert_eq!(status, axum::http::StatusCode::OK, "{body}"); - let sid = body["data"]["paneContent"]["sessionRef"]["sessionId"].as_str().unwrap().to_string(); - let tid = body["data"]["paneContent"]["terminalId"].as_str().unwrap().to_string(); + // The REST body carries only ids; the minted sid comes from the + // registry row (same read as Task 2's assertion 2). + let tid = body["data"]["terminalId"].as_str().unwrap().to_string(); + let sid = registry.identity_probe_rows().into_iter() + .find(|r| r.terminal_id == tid).and_then(|r| r.resume_session_id) + .expect("minted id in the registry row"); let events = binder.events(); let prespawn = events.iter().position(|e| e == &format!("prespawn:{tid}:{sid}")) @@ -957,7 +983,7 @@ async fn resume_claude_rest_create_registers_identity_without_prespawn_write() { "sessionRef": {"provider": "claude", "sessionId": S}, }), true).await; assert_eq!(status, axum::http::StatusCode::OK, "{body}"); - let tid = body["data"]["paneContent"]["terminalId"].as_str().unwrap().to_string(); + let tid = body["data"]["terminalId"].as_str().unwrap().to_string(); let events = binder.events(); assert!(!events.iter().any(|e| e.starts_with("prespawn:")), @@ -1000,7 +1026,7 @@ async fn rest_pane_exit_retires_identity_via_binder() { let (status, body) = post(app(state), "/api/tabs", serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), true).await; assert_eq!(status, axum::http::StatusCode::OK, "{body}"); - let tid = body["data"]["paneContent"]["terminalId"].as_str().unwrap().to_string(); + let tid = body["data"]["terminalId"].as_str().unwrap().to_string(); registry.kill(&tid); // The exit hook runs asynchronously — poll with a bounded deadline. @@ -1234,14 +1260,19 @@ async fn spawn_merged_server() -> Harness { async fn rest_create_claude(h: &Harness) -> (String, String) { // PORT: rest_ws_shared_gate.rs's raw HTTP POST helper, verbatim — // POST /api/tabs {"mode":"claude","cwd":} with x-auth-token. - // Returns (terminal_id, session_id) read from the response's - // paneContent.sessionRef, panicking with the full body on any miss. + // Returns (terminal_id, session_id): terminal_id from the response + // body's data.terminalId (the REST HTTP body carries ONLY + // {tabId, paneId, terminalId} — paneContent.sessionRef travels on the + // broadcast ui.command frame, not the body), session_id from the + // harness's registry row (registry.identity_probe_rows() -> + // resume_session_id for that terminal). Panic with the full body / + // row set on any miss. } /// 4a — a REST-created claude pane has a USABLE RESUME IDENTITY that does /// not depend on any signal file existing: preallocated id in the registry -/// row, identity row, durable Bound ledger binding, sessionRef in the -/// create response — with the signal directory EMPTY throughout. +/// row (readable at create time — the rung-0 feed), identity row, and a +/// durable Bound ledger binding — with the signal directory EMPTY throughout. #[tokio::test(flavor = "multi_thread")] async fn rest_created_claude_pane_has_durable_resume_identity_without_signals() { let h = spawn_merged_server().await; @@ -1397,7 +1428,7 @@ Fill the two `todo!()` helpers by porting the cited code (`rest_ws_shared_gate.r - [ ] **Step 2: Run the suite** Run: `cargo test -p freshell-ws --test rest_claude_identity` -Expected: all PASS. (These are green-on-arrival pins for Tasks 2+5's behavior — the value is that they fail loudly if anyone reintroduces the split. Verify each is a REAL pin by spot-reverting: `git stash` nothing — instead temporarily flip the `launch_intent` conditional in `terminal_tabs.rs` back to bare `LaunchIntent::Resume`, rerun, confirm 4a/4b/4c go red, restore.) +Expected: all PASS. (These are green-on-arrival pins for Tasks 2+5's behavior — the value is that they fail loudly if anyone reintroduces the split. Verify each is a REAL pin by spot-reverting the MINT itself: temporarily force `claude_fresh_prealloc` to `false` in `spawn_terminal_pane` — i.e. replace the `should_preallocate_fresh_claude(..)` call's result with a literal `false` — rerun, confirm 4a/4b/4c go red, restore. Do NOT use the `launch_intent` conditional as the spot-revert: flipping it only changes argv, while 4a/4b/4c assert identity/ledger/refusal properties gated on `claude_fresh_prealloc`, so they would stay green and prove nothing.) - [ ] **Step 3: Reconcile `pane_ledger_restore.rs:237-302`** @@ -1557,7 +1588,7 @@ Expected: branch pushed. STOP — do not open a PR, do not close kata hbsa (the | P1 mint preallocated UUID + `--session-id` argv | Tasks 1–2 (argv asserted in Task 2 Step 1) | | P1 pre-spawn ledger binding (PIN 2, eaa25b7d scoping) | Tasks 4–5 (write + failure-delete, same gate; ordering pinned in Task 5 Step 1) | | P1 identity row registration | Tasks 4–5 (unit) + Task 6 4a (e2e) | -| P1 sessionRef exposed (REST response, `GET /api/terminals` rung 0, `paneContent`) | Task 2 (response + registry row = rung 0 input; `terminals.rs` needs no change) | +| P1 sessionRef exposed (broadcast `ui.command` `paneContent`, `GET /api/terminals` rung 0, registry row — the REST HTTP bodies carry only ids and are deliberately unchanged) | Task 2 (broadcast paneContent + registry row = rung 0 input; `terminals.rs` needs no change) | | P1 SessionStart signal consumed as confirmation | Task 6 4c | | 2 split + respawn entry points | Task 3 (shared `spawn_terminal_pane`, pinned) | | 2 other entry points audit | Settled by load-bearing validation (ledger A12): exactly THREE live REST spawn routes (tabs, split, respawn), all funnel through `spawn_terminal_pane` (`terminal_tabs.rs:704`); `POST /api/tabs-sync/restore` was deliberately deleted (docs/plans/2026-07-26-recover-my-panes.md Task 9, kata h9vt) — the `:193-195` comment is stale; the deferred-restore variant (`:203`) has zero callers and inherits the fix if revived; the WS auto-resume respawn door (`terminal.rs:2945`) is identity-preserving by reuse and out of scope | From 4709ee8aaa48dc367a108222fc34feab712edd41 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:47:13 -0700 Subject: [PATCH 06/15] =?UTF-8?q?docs(plan):=20fix=20fresheyes=20blocking?= =?UTF-8?q?=20issues=20=E2=80=94=20respawn=20test=20body=20carries=20mode:?= =?UTF-8?q?claude=20(respawn=20is=20body-driven,=20empty=20body=20respawns?= =?UTF-8?q?=20shell);=20retire=5Fpane=5Fidentity=20mirrors=20the=20natural?= =?UTF-8?q?-exit=20hook=20only=20(no=20retire=5Fclosed,=20Bound=20row=20su?= =?UTF-8?q?rvives=20natural=20exit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-07-29-rest-terminal-session-identity.md | 78 +++++++++++++------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/docs/plans/2026-07-29-rest-terminal-session-identity.md b/docs/plans/2026-07-29-rest-terminal-session-identity.md index 3607252bb..299ecd4ff 100644 --- a/docs/plans/2026-07-29-rest-terminal-session-identity.md +++ b/docs/plans/2026-07-29-rest-terminal-session-identity.md @@ -477,6 +477,11 @@ async fn respawn_pane_claude_ends_with_session_identity() { // respawned claude pane must end with a real sessionRef (whether the // respawn resumes the prior id or mints fresh is respawn policy, pinned // elsewhere — the bug here was ending with NO identity at all). + // NOTE: respawn identity is BODY-driven, not pane-inherited — the + // client body is forwarded untouched (pane_ops.rs:716) and + // spawn_terminal_pane derives mode solely from body["mode"], defaulting + // to "shell" (terminal_tabs.rs:710-715). An empty body respawns a SHELL + // pane (no mint). The body below must therefore carry mode:"claude". let (state, registry, _capture) = state_with_claude_capture_spec(); let router = app(state); @@ -487,7 +492,7 @@ async fn respawn_pane_claude_ends_with_session_identity() { .expect("pane id").to_string(); let (status, respawned) = post(router, &format!("/api/panes/{pane_id}/respawn"), - serde_json::json!({}), true).await; + serde_json::json!({"mode":"claude"}), true).await; assert_eq!(status, axum::http::StatusCode::OK, "respawn failed: {respawned}"); // The respawn body is {terminalId} only — identity via the registry row. let respawn_tid = respawned["data"]["terminalId"].as_str() @@ -570,19 +575,27 @@ pub trait PaneIdentityBinder: Send + Sync + std::fmt::Debug { cwd: Option<&str>, create_request_id: Option<&str>, ); - /// Exit-side hygiene (load-bearing ledger A2): mirrors the WS kill path - /// (terminal.rs:1334-1342, :3860-3868, :3896) — retire the identity row - /// (in-memory flag flip), delete any pending marker, retire closed - /// ledger bindings for this terminal. Without it, dead REST panes stay - /// live-looking: the session directory lists them as running - /// (session_directory.rs:716-766), the reverse rename cascade fires on - /// them (sessions.rs:167-187), and a late new-id SessionStart skips the - /// `current.retired -> Acted` no-op arm and durably rebinds a dead pane - /// (claude_signal.rs:253-342). Idempotent; harmless no-op for terminals - /// with no identity row. Called from the pane exit hook for ALL - /// non-shell creates. SYNC ON PURPOSE: the exit hook is a plain FnOnce - /// on the PTY reader thread — blocking IO is safe there, .await is - /// impossible (mirrors the WS exit hook, terminal.rs:1334-1342). + /// Exit-side hygiene (load-bearing ledger A2): mirrors the WS pane + /// EXIT hook (terminal.rs:1334-1342) EXACTLY — retire the identity row + /// (in-memory flag flip) and delete any pending marker. Deliberately + /// does NOT touch the ledger binding: `retire_closed` is the + /// explicit-user-close trigger only ("P1.8 trigger (e)", the WS kill + /// command path, terminal.rs:3849-3868), never the natural-exit path, + /// and the Bound-after-natural-exit ledger row is load-bearing — + /// `auto_resume::pre_respawn_guard` reads a still-Bound row as "pane + /// still wants this session" (auto_resume.rs:445-450) and the recovery + /// inventory keys on `RetiredReason::Closed` meaning deliberate close + /// (recovery_inventory.rs:299-301). Both A2 hazards are closed by the + /// identity-row retire alone: the session directory joins identity + /// rows for liveness (session_directory.rs:716-766, and the rename + /// cascade with it, sessions.rs:167-187), and the claude drain's no-op + /// arm checks `current.retired` (claude_signal.rs:253-342), so a late + /// new-id SessionStart cannot durably rebind a dead pane. Idempotent; + /// harmless no-op for terminals with no identity row. Called from the + /// pane exit hook for ALL non-shell creates. SYNC ON PURPOSE: the exit + /// hook is a plain FnOnce on the PTY reader thread — blocking IO is + /// safe there, .await is impossible (mirrors the WS exit hook, + /// terminal.rs:1334-1342). fn retire_pane_identity(&self, terminal_id: &str); } ``` @@ -682,15 +695,28 @@ mod tests { // Ledger A2: exit-side hygiene — retired rows must stop looking live. // Sync test on purpose: retire MUST be callable with no runtime, // because production calls it from the PTY reader thread's exit hook. - let (b, _ledger, identity, dir) = binder("retire"); + let (b, ledger, identity, dir) = binder("retire"); b.register_create_identity("t-rest-4", "claude", Some(SID), Some("/tmp"), None); b.retire_pane_identity("t-rest-4"); - // Retired == invisible to live lookups, exactly what the WS kill path - // produces (match the accessor the identity.rs retire tests use — - // e.g. the live find_by_session no longer returns the terminal, - // while the retired-inclusive lookup still does). + // Retired == invisible to live lookups, exactly what the WS pane + // EXIT hook produces (match the accessor the identity.rs retire + // tests use — e.g. the live find_by_session no longer returns the + // terminal, while the retired-inclusive lookup still does). assert!(identity.find_by_session("claude", SID).is_none(), "retired row is not a live owner"); + // NATURAL-EXIT contract pin: the durable ledger binding must STAY + // Bound — retire_closed is the explicit-kill trigger + // (terminal.rs:3849-3868), never the exit hook's. A still-Bound row + // after natural exit is load-bearing for + // auto_resume::pre_respawn_guard and the recovery inventory's + // RetiredReason::Closed keying. Assert with the ledger read API the + // pane_ledger tests use (e.g. load_binding("claude", SID)) that the + // row still exists and is Bound (not retired/Closed). + let binding = ledger.load_binding("claude", SID) + .expect("natural exit must NOT retire the ledger binding"); + // assert the row state is Bound — match the RowState/retired + // accessor the pane_ledger tests use. + let _ = binding; // And the pending-marker delete arm: register a marker-mode pane, // retire it, assert its pending/.json is gone (same // marker-read idiom as the markers test above). @@ -840,11 +866,17 @@ impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinde } fn retire_pane_identity(&self, terminal_id: &str) { - // PORT (not new design): the WS kill-path hygiene — identity retire - // (terminal.rs:1334 / :3896), pending-marker delete (:1342 / :3866), - // and retire_closed for the terminal's ledger rows (:3860-3868) — + // PORT (not new design): the WS pane EXIT hook ONLY — identity + // retire (terminal.rs:1334) + pending-marker delete (:1342) — // substituting self.identity / self.ledger and warn_write_failure, - // all called directly (sync). This method MUST stay runtime-free: + // both called directly (sync). Do NOT port `retire_closed` from + // the kill path (:3860-3868): that is the explicit-user-close + // trigger (P1.8 trigger (e)); a natural exit or crash must leave + // the ledger binding Bound, exactly like a WS pane, so + // auto_resume::pre_respawn_guard (auto_resume.rs:445-450) and the + // recovery inventory (RetiredReason::Closed keying, + // recovery_inventory.rs:299-301) still read the row correctly. + // This method MUST stay runtime-free: // production calls it from the PTY reader thread's exit hook, where // blocking IO is safe and tokio does not exist. The identity retire // is an in-memory flag flip; this method changes NO drain logic From 180a15e9dd9af352b88231c1a056303cd86bedb9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:57:43 -0700 Subject: [PATCH 07/15] refactor(platform): shared fresh-claude preallocation predicate for both create doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-platform/src/cli_launch.rs | 57 ++++++++++++++++++++++ crates/freshell-platform/src/lib.rs | 1 + crates/freshell-ws/src/terminal.rs | 16 +++--- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/freshell-platform/src/cli_launch.rs b/crates/freshell-platform/src/cli_launch.rs index 076f098d4..35349f84e 100644 --- a/crates/freshell-platform/src/cli_launch.rs +++ b/crates/freshell-platform/src/cli_launch.rs @@ -597,6 +597,34 @@ pub fn resolve_cli_launch( label: spec.label.clone(), }) } +// =========================================================================== +// Fresh-claude preallocation predicate (shared with REST spawn pipeline) +// =========================================================================== + +/// LIVE-PATH LAW (specs/coding-cli.md § 2.1(3)): fresh claude ALWAYS gets a +/// server-preallocated `--session-id`. This is the single shared "is this +/// create a fresh claude that must mint its own session id?" predicate, +/// used by BOTH doors — the WS `terminal.create` handler +/// (`freshell-ws/src/terminal.rs`) and the REST spawn pipeline +/// (`freshell-freshagent/src/terminal_tabs.rs`) — so the two cannot drift +/// (kata hbsa: the REST door skipped preallocation entirely, leaving +/// un-resumable panes invisible to the A13 live-owner guard). +/// +/// The caller that gets `true` mints `Uuid::new_v4()`, sets +/// `LaunchIntent::Start` (claude's manifest has `create_session_args`), +/// and marks the create as a fresh prealloc for PIN 2 gating (eaa25b7d). +pub fn should_preallocate_fresh_claude( + mode: &str, + restore: Option, + has_session_ref: bool, + resume_session_id: Option<&str>, +) -> bool { + mode == "claude" + && restore != Some(true) + && !has_session_ref + && resume_session_id.filter(|s| !s.is_empty()).is_none() +} + // §4 golden argv tests (split to keep this file within the campaign's // ≤1K-lines-per-file limit). #[cfg(test)] @@ -661,6 +689,35 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn fresh_claude_preallocation_predicate_truth_table() { + use super::should_preallocate_fresh_claude as pred; + // The three-part freshness predicate from the WS reference + // (crates/freshell-ws/src/terminal.rs:1630-1637): mode == "claude" + // AND restore != Some(true) AND no sessionRef AND no non-empty + // resumeSessionId. + assert!(pred("claude", None, false, None)); + assert!(pred("claude", Some(false), false, None)); + // Empty resume id is treated as absent (matches the WS + // `.filter(|s| !s.is_empty()).is_none()` shape). + assert!(pred("claude", None, false, Some(""))); + // Any disqualifier kills the mint: + assert!(!pred("claude", Some(true), false, None)); // restore create + assert!(!pred("claude", None, true, None)); // wire sessionRef present + assert!(!pred( + "claude", + None, + false, + Some("29a53649-0000-4000-8000-000000000000") + )); // resume + // Only claude mints with Start intent; other providers never do here: + assert!(!pred("shell", None, false, None)); + assert!(!pred("codex", None, false, None)); + assert!(!pred("amplifier", None, false, None)); + assert!(!pred("opencode", None, false, None)); + assert!(!pred("gemini", None, false, None)); + } + /// GNU date passthrough: full nanosecond precision is preserved. #[cfg(unix)] #[test] diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 9dd6e386d..98bfc113c 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -64,6 +64,7 @@ pub mod firewall; pub mod network; pub mod port_forward; +pub use cli_launch::should_preallocate_fresh_claude; pub use detect::{HostOs, Platform}; pub use elevated::{ build_elevated_powershell_args, ConfirmationAction, ConfirmationGate, diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index d41e24ed0..07ece6cec 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1627,14 +1627,14 @@ pub(crate) async fn handle_create( let mut claude_fresh_prealloc = false; if mode != "shell" { let requested_ref = create.session_ref.as_ref().filter(|r| r.provider == mode); - let should_preallocate_fresh_claude = mode == "claude" - && create.restore != Some(true) - && create.session_ref.is_none() - && create - .resume_session_id - .as_deref() - .filter(|s| !s.is_empty()) - .is_none(); + // Shared with the REST spawn pipeline (kata hbsa) — one predicate, + // two doors: freshell_platform::should_preallocate_fresh_claude. + let should_preallocate_fresh_claude = freshell_platform::should_preallocate_fresh_claude( + &mode, + create.restore, + create.session_ref.is_some(), + create.resume_session_id.as_deref(), + ); // Launcher-assigned amplifier identity (kata qmpk), the fresh-claude // preallocation's sibling: a FRESH amplifier pane gets a // server-minted session id, and (below, in the pre-create block) a From 4d46c86ac63e03e838bd48dbe8fc8396ed183b87 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:15:47 -0700 Subject: [PATCH 08/15] fix(freshagent): REST claude creates mint a preallocated --session-id (WS parity, kata hbsa) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh POST /api/tabs {mode:"claude"} now mints a server-preallocated session UUID via the shared freshell_platform::should_preallocate_fresh_claude predicate (Task 1), flips the CLI launch intent to LaunchIntent::Start so `claude --session-id ` lands in argv, and threads a claude_fresh_prealloc flag through GatedSettleInputs for Task 5's PIN 2-gated binder call sites (eaa25b7d symmetry). derive_resume_identity additionally returns the pre-provider-filter locator parse result (ledger A1: "sessionRef": null must still mint, matching the WS door's serde Option semantics). The tab_create_missing_session_identity warn gains an explicit paneContent.sessionRef guard — a create that ended up with real identity must not alarm. Minted ids claim no D7/D8 sessionRef lease (accepted_session_ref stays None), and REST HTTP bodies are unchanged — sessionRef surfaces on the broadcast ui.command paneContent and the registry row. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../freshell-freshagent/src/terminal_tabs.rs | 317 +++++++++++++++++- 1 file changed, 305 insertions(+), 12 deletions(-) diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 3e01e4fd7..6d99ddb8b 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -487,15 +487,25 @@ fn arm_locators_for_fresh_pane( /// on the `{provider,sessionId}` shape gives the same "well-formed or `None`" behavior a /// wrong-shaped JSON value would (`Err` -> `None`, since `SessionLocator`'s fields are /// non-optional strings). +/// +/// The third element is the PRE-provider-filter parse result +/// (`session_ref_locator_present`: the `serde_json::from_value::` +/// parse above succeeded) — the fresh-claude preallocation predicate's +/// `has_session_ref` input (kata hbsa, ledger A1). It matches the WS door's +/// `create.session_ref.is_none()` on every mutually-accepted body shape +/// (absent / `null` / well-formed locator of ANY provider — any parsed +/// locator disables the mint), where the raw `body.get("sessionRef").is_some()` +/// check would see `Some(Value::Null)` and wrongly skip the mint. #[allow(clippy::result_large_err)] pub(crate) fn derive_resume_identity( body: &Value, mode: &str, -) -> Result<(Option, Option), Response> { +) -> Result<(Option, Option, bool), Response> { let session_ref: Option = body .get("sessionRef") .cloned() .and_then(|v| serde_json::from_value(v).ok()); + let session_ref_locator_present = session_ref.is_some(); let legacy_resume_session_id = body .get("resumeSessionId") .and_then(Value::as_str) @@ -506,7 +516,11 @@ pub(crate) fn derive_resume_identity( legacy_resume_session_id.as_deref(), )?; let accepted_session_ref = accepted_session_ref_for_mode(session_ref.as_ref(), mode).cloned(); - Ok((resume_session_id, accepted_session_ref)) + Ok(( + resume_session_id, + accepted_session_ref, + session_ref_locator_present, + )) } /// The successful result of [`spawn_terminal_pane`]: the `paneContent` JSON + the @@ -764,7 +778,29 @@ pub(crate) async fn spawn_terminal_pane( } } - let (mut resume_session_id, accepted_session_ref) = derive_resume_identity(body, &mode)?; + let (mut resume_session_id, accepted_session_ref, session_ref_locator_present) = + derive_resume_identity(body, &mode)?; + + // Fresh-claude preallocation (kata hbsa): WS parity. The WS door's + // fresh-claude special case (freshell-ws/src/terminal.rs, LIVE-PATH LAW + // spec §2.1(3)) mints a server-preallocated --session-id for every fresh + // claude create; this REST door historically did not (legacy router.ts + // lineage), leaving REST claude panes un-resumable and invisible to the + // A13 live-owner guard. Same predicate, same mint, both doors. + // + // PIN 2 (eaa25b7d): `claude_fresh_prealloc` marks that THIS create minted + // the id — the pre-spawn ledger write and its spawn-failure delete (Task 5 + // call sites in settle_gated_create) are BOTH gated on this exact flag, + // never on `mode == "claude"`. + let claude_fresh_prealloc = freshell_platform::should_preallocate_fresh_claude( + &mode, + body.get("restore").and_then(serde_json::Value::as_bool), + session_ref_locator_present, + resume_session_id.as_deref(), + ); + if claude_fresh_prealloc { + resume_session_id = Some(Uuid::new_v4().to_string()); + } // Hoisted spawn-environment inputs, computed ONCE (Task 8's WS pattern, // REST twin): the amplifier windows-arm guard below and the spawn-spec @@ -1104,6 +1140,7 @@ pub(crate) async fn spawn_terminal_pane( cwd, resume_session_id, accepted_session_ref, + claude_fresh_prealloc, create_request_id, session_ref_lease, registry, @@ -1138,6 +1175,13 @@ struct GatedSettleInputs { cwd: Option, resume_session_id: Option, accepted_session_ref: Option, + /// Fresh-claude preallocation (kata hbsa): `true` iff THIS create minted + /// its own `--session-id` (the [`freshell_platform::should_preallocate_fresh_claude`] + /// predicate fired in [`spawn_terminal_pane`]). Selects `LaunchIntent::Start` + /// below, and PIN 2 (eaa25b7d): the pre-spawn ledger write and its + /// spawn-failure delete (Task 5 call sites) are BOTH gated on this exact + /// flag, never on `mode == "claude"`. + claude_fresh_prealloc: bool, create_request_id: String, session_ref_lease: Option, registry: freshell_terminal::TerminalRegistry, @@ -1176,6 +1220,7 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Result freshell_platform::CliCommandSpec { + let mut spec = recording_cli_spec("claude", argv_file); + spec.create_session_args = Some(vec![ + "--session-id".to_string(), + "{{sessionId}}".to_string(), + ]); + spec + } + + /// Harness for the fresh-claude prealloc tests (the `:3321` opencode + /// spawning test's state/spec/argv-capture idiom, spec swapped for the + /// claude one above): state + shared registry + argv capture path. + fn state_with_claude_capture_spec( + label: &str, + ) -> ( + FreshAgentState, + freshell_terminal::TerminalRegistry, + std::path::PathBuf, + ) { + let argv_file = unique_argv_file(label); + let state = state_with_registry().with_cli_commands(std::sync::Arc::new(vec![ + claude_prealloc_recording_cli_spec(&argv_file), + ])); + let registry = state.terminal_registry.clone().expect("registry wired"); + (state, registry, argv_file) + } + + /// Subscribe BEFORE the POST — the `:3383` sibling's broadcast-capture + /// idiom (`state.broadcast_tx.subscribe()`). + fn subscribe_broadcast_frames( + state: &FreshAgentState, + ) -> tokio::sync::broadcast::Receiver { + state.broadcast_tx.subscribe() + } + + /// Read the next `ui.command{tab.create}` frame off the broadcast channel + /// and return its `payload.paneContent` (the `:3383` frame-reading idiom). + async fn next_ui_command_pane_content( + frames: &mut tokio::sync::broadcast::Receiver, + ) -> Value { + let frame = frames.recv().await.expect("ui.command frame broadcast"); + let msg: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(msg["command"], json!("tab.create"), "{msg}"); + msg["payload"]["paneContent"].clone() + } + + /// The `:3321` argv-capture idiom, split into one-arg-per-line entries + /// (the recorder script's `printf '%s\n' "$@"`). + async fn wait_for_captured_argv(path: &std::path::Path) -> Vec { + read_argv_file_eventually(path) + .await + .lines() + .map(str::to_string) + .collect() + } + + #[tokio::test] + async fn create_fresh_claude_tab_preallocates_session_identity() { + // kata hbsa P1: REST parity with the WS fresh-claude special case. + // A fresh POST /api/tabs {mode:"claude"} must mint a --session-id, + // carry it in the registry row, and expose it as paneContent.sessionRef + // on the broadcast `ui.command` frame. NOTE the surfaces: the REST HTTP + // body carries ONLY {tabId, paneId, terminalId} (terminal_tabs.rs: + // 1828-1832) — paneContent (and its sessionRef) travels on the broadcast + // frame, because the REST route always calls with broadcast=true + // (terminal_tabs.rs:196-197). + let (state, registry, argv_capture_path) = + state_with_claude_capture_spec("claude-prealloc"); + // Subscribe BEFORE the POST, exactly the way the sibling test at :3383 + // captures its ui.command frames off the state's broadcast channel — + // reuse that subscription + frame-reading code verbatim. + let mut frames = subscribe_broadcast_frames(&state); + let (status, body) = post( + app(state), + "/api/tabs", + serde_json::json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + }), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "create failed: {body}"); + + // 1. sessionRef surfaced on the broadcast paneContent (the create-time + // reporting surface — the HTTP body has NO paneContent). Read the + // ui.command frame the same way the :3383 sibling does. + let pane_content = next_ui_command_pane_content(&mut frames).await; + let session_ref = pane_content["sessionRef"].clone(); + assert_eq!( + session_ref["provider"], + serde_json::json!("claude"), + "sessionRef: {pane_content}" + ); + let sid = session_ref["sessionId"] + .as_str() + .expect("sessionId string") + .to_string(); + uuid::Uuid::parse_str(&sid).expect("preallocated id is a canonical UUID"); + + // 2. Registry row carries the id (this is GET /api/terminals rung 0, + // terminals.rs:686-698 — populating it makes sessionRef real there + // with zero changes to terminals.rs). + let terminal_id = body["data"]["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + let row = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == terminal_id) + .expect("registry row exists"); + assert_eq!(row.resume_session_id.as_deref(), Some(sid.as_str())); + + // 3. argv proof: `claude --session-id ` (LaunchIntent::Start), + // NOT `--resume` and NOT bare argv. + let argv = wait_for_captured_argv(&argv_capture_path).await; + let pos = argv + .iter() + .position(|a| a == "--session-id") + .expect("--session-id in argv"); + assert_eq!(argv.get(pos + 1).map(String::as_str), Some(sid.as_str())); + assert!( + !argv.iter().any(|a| a == "--resume"), + "fresh create must not resume: {argv:?}" + ); + + registry.kill(&terminal_id); + let _ = std::fs::remove_file(&argv_capture_path); + } + + #[tokio::test] + async fn create_fresh_claude_tab_with_null_session_ref_still_mints() { + // Ledger A1 regression: `"sessionRef": null` is ABSENT on both doors. + // Same harness and assertions as + // create_fresh_claude_tab_preallocates_session_identity, with + // `"sessionRef": serde_json::Value::Null` added to the POST body — + // the broadcast paneContent must still carry a minted claude sessionRef. + // (WS deserializes `"sessionRef": null` to `None` and MINTS, + // client_messages.rs:233-234; a raw `body.get("sessionRef").is_some()` + // check would see `Some(Value::Null)` and skip the mint — the + // predicate input must be the PARSED locator presence.) + let (state, registry, argv_capture_path) = + state_with_claude_capture_spec("claude-null-sref"); + let mut frames = subscribe_broadcast_frames(&state); + let (status, body) = post( + app(state), + "/api/tabs", + serde_json::json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": serde_json::Value::Null, + }), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "create failed: {body}"); + + let pane_content = next_ui_command_pane_content(&mut frames).await; + let session_ref = pane_content["sessionRef"].clone(); + assert_eq!( + session_ref["provider"], + serde_json::json!("claude"), + "sessionRef: {pane_content}" + ); + let sid = session_ref["sessionId"] + .as_str() + .expect("sessionId string") + .to_string(); + uuid::Uuid::parse_str(&sid).expect("preallocated id is a canonical UUID"); + + let terminal_id = body["data"]["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + let row = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == terminal_id) + .expect("registry row exists"); + assert_eq!(row.resume_session_id.as_deref(), Some(sid.as_str())); + + let argv = wait_for_captured_argv(&argv_capture_path).await; + let pos = argv + .iter() + .position(|a| a == "--session-id") + .expect("--session-id in argv"); + assert_eq!(argv.get(pos + 1).map(String::as_str), Some(sid.as_str())); + assert!( + !argv.iter().any(|a| a == "--resume"), + "fresh create must not resume: {argv:?}" + ); + + registry.kill(&terminal_id); + let _ = std::fs::remove_file(&argv_capture_path); + } + #[tokio::test] async fn create_codex_tab_rejects_raw_resume_session_id_without_session_ref() { let argv_file = unique_argv_file("codex-reject"); @@ -4729,6 +4993,35 @@ mod tests { let _ = std::fs::remove_file(&argv_file); } + #[tokio::test] + async fn create_fresh_claude_tab_does_not_warn_missing_identity() { + // kata hbsa: the mint closes the identity gap, so the invariant alarm + // must stay quiet for fresh claude REST creates. + // (Same harness as create_tab_with_identity_or_shell_mode_does_not_warn_invariant, + // with a fresh {mode:"claude"} body and no sessionRef/resumeSessionId.) + let (events, _guard) = invariant_capture::capture(); + let (state, registry, argv_file) = state_with_claude_capture_spec("claude-no-warn"); + let tmp = std::env::temp_dir(); + let (status, body) = post( + app(state), + "/api/tabs", + json!({ "mode": "claude", "cwd": tmp.to_string_lossy() }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let terminal_id = body["data"]["terminalId"].as_str().unwrap().to_string(); + + assert!( + missing_identity_warnings(&events.lock().unwrap()).is_empty(), + "a fresh claude create mints its own identity (paneContent.sessionRef) \ + and must not trip the missing-identity alarm" + ); + + registry.kill(&terminal_id); + let _ = std::fs::remove_file(&argv_file); + } + #[tokio::test] async fn capture_browser_pane_is_422_use_screenshot_pane() { let state = state_with_registry(); From f40a49c08940fbec84d2969427713732a6814a32 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:30:28 -0700 Subject: [PATCH 09/15] test(freshagent): pin claude identity mint on REST split and respawn entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../freshell-freshagent/src/terminal_tabs.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 6d99ddb8b..de8d4bcd7 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -3620,6 +3620,135 @@ mod tests { let _ = std::fs::remove_file(&argv_capture_path); } + #[tokio::test] + async fn split_pane_claude_preallocates_fresh_session_identity() { + // kata hbsa P2: POST /api/panes/:id/split shares spawn_terminal_pane, + // so a claude split must mint its OWN fresh identity (distinct from + // the source pane's). + let (state, registry, _capture) = state_with_claude_capture_spec("claude-split"); + let router = app(state); + + let (status, tab) = post( + router.clone(), + "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + // The /api/tabs body is {tabId, paneId, terminalId} — no paneContent. + // Identity is read from the registry rows (rung 0), keyed by terminalId. + let pane_id = tab["data"]["paneId"] + .as_str() + .expect("pane id in create response") + .to_string(); + let first_tid = tab["data"]["terminalId"] + .as_str() + .expect("terminal id in create response") + .to_string(); + let first_sid = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == first_tid) + .expect("create registry row") + .resume_session_id + .expect("first pane minted"); + + let (status, split) = post( + router, + &format!("/api/panes/{pane_id}/split"), + serde_json::json!({"mode":"claude"}), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "split failed: {split}"); + // The split body is {paneId, terminalId} — again, identity via registry. + let split_tid = split["data"]["terminalId"] + .as_str() + .expect("terminal id in split response") + .to_string(); + let split_sid = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == split_tid) + .expect("split registry row") + .resume_session_id + .expect("split minted"); + uuid::Uuid::parse_str(&split_sid).expect("canonical UUID"); + assert_ne!(split_sid, first_sid, "split must mint its OWN identity"); + + // Registry rows for BOTH panes carry their ids. + let rows = registry.identity_probe_rows(); + assert_eq!( + rows.iter() + .filter(|r| r.resume_session_id.is_some()) + .count(), + 2, + "both claude panes carry resume identity: {rows:?}" + ); + for r in rows { + registry.kill(&r.terminal_id); + } + let _ = std::fs::remove_file(&_capture); + } + + #[tokio::test] + async fn respawn_pane_claude_ends_with_session_identity() { + // kata hbsa P2: POST /api/panes/:id/respawn also funnels through + // spawn_terminal_pane. The pin is the identity GAP being closed: the + // respawned claude pane must end with a real sessionRef (whether the + // respawn resumes the prior id or mints fresh is respawn policy, pinned + // elsewhere — the bug here was ending with NO identity at all). + // NOTE: respawn identity is BODY-driven, not pane-inherited — the + // client body is forwarded untouched (pane_ops.rs:716) and + // spawn_terminal_pane derives mode solely from body["mode"], defaulting + // to "shell" (terminal_tabs.rs:710-715). An empty body respawns a SHELL + // pane (no mint). The body below must therefore carry mode:"claude". + let (state, registry, _capture) = state_with_claude_capture_spec("claude-respawn"); + let router = app(state); + + let (status, tab) = post( + router.clone(), + "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + let pane_id = tab["data"]["paneId"].as_str().expect("pane id").to_string(); + + let (status, respawned) = post( + router, + &format!("/api/panes/{pane_id}/respawn"), + serde_json::json!({"mode":"claude"}), + true, + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::OK, + "respawn failed: {respawned}" + ); + // The respawn body is {terminalId} only — identity via the registry row. + let respawn_tid = respawned["data"]["terminalId"] + .as_str() + .expect("terminal id in respawn response") + .to_string(); + let sid = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == respawn_tid) + .expect("respawn registry row") + .resume_session_id + .expect("respawned pane has identity"); + uuid::Uuid::parse_str(&sid).expect("canonical UUID"); + + for r in registry.identity_probe_rows() { + registry.kill(&r.terminal_id); + } + let _ = std::fs::remove_file(&_capture); + } + #[tokio::test] async fn create_codex_tab_rejects_raw_resume_session_id_without_session_ref() { let argv_file = unique_argv_file("codex-reject"); From 4fbc9c9fd504d2c535c3c6d79300341a3833d144 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:43:10 -0700 Subject: [PATCH 10/15] =?UTF-8?q?feat(ws,terminal):=20PaneIdentityBinder?= =?UTF-8?q?=20seam=20=E2=80=94=20write-side=20identity/ledger=20bridge=20f?= =?UTF-8?q?or=20the=20REST=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of the REST terminal session-identity plan (kata hbsa): a synchronous PaneIdentityBinder trait in freshell-terminal (SessionIdentityLookup's write-side twin) and its production impl LedgerPaneIdentityBinder in freshell-ws, mirroring the WS fresh-claude path's registry+ledger writes (PIN2 pre-spawn binding + failure-delete, post-spawn identity/binding/ pending block) and the exit hook's retire + pending-delete hygiene. Ledger writes are best-effort: warn and proceed, never block a create. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-terminal/src/registry.rs | 63 ++++ crates/freshell-ws/src/lib.rs | 1 + .../freshell-ws/src/pane_identity_binder.rs | 309 ++++++++++++++++++ crates/freshell-ws/src/terminal.rs | 2 +- 4 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 crates/freshell-ws/src/pane_identity_binder.rs diff --git a/crates/freshell-terminal/src/registry.rs b/crates/freshell-terminal/src/registry.rs index 0e8430e92..83d4c4be5 100644 --- a/crates/freshell-terminal/src/registry.rs +++ b/crates/freshell-terminal/src/registry.rs @@ -640,6 +640,69 @@ pub trait SessionIdentityLookup: Send + Sync + std::fmt::Debug { fn terminal_for_session(&self, provider: &str, session_id: &str) -> Option; } +/// Write-side pane-identity seam — [`SessionIdentityLookup`]'s twin for the +/// REST spawn pipeline (kata hbsa). Consumed by `freshell-freshagent`'s REST +/// create/split/respawn call sites (which cannot depend on `freshell-ws` — +/// circular); produced by `freshell-ws`'s `LedgerPaneIdentityBinder` +/// (identity registry + pane ledger), wired in `freshell-server::main`. +/// +/// Fully synchronous ON PURPOSE: every underlying operation is sync (the +/// identity registry is a plain `RwLock`, every ledger writer a plain +/// `fn -> io::Result<()>`), and the one caller that CANNOT be async is the +/// pane exit hook — [`crate::pty::ExitHook`] is a `FnOnce` invoked on the +/// plain OS reader thread where no tokio runtime exists. Async REST call +/// sites hop ledger-touching calls through `tokio::task::spawn_blocking`. +pub trait PaneIdentityBinder: Send + Sync + std::fmt::Debug { + /// PIN 2 durability-before-argv: durable claude binding row written + /// BEFORE the spawn makes the preallocated id observable. Callers gate + /// this on their fresh-prealloc flag ONLY (eaa25b7d). + fn record_prespawn_claude_binding( + &self, + session_id: &str, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + create_request_id: Option<&str>, + ); + /// Compensating delete when the spawn that minted the id fails. + /// MUST be gated on the SAME predicate as the record (eaa25b7d). + fn delete_prespawn_claude_binding(&self, session_id: &str); + /// Post-spawn identity registration, mirroring the WS post-spawn block + /// (freshell-ws/src/terminal.rs): identity row + durable binding for any + /// non-shell create with a session id; pending marker for the + /// locator-resolved providers (codex/opencode/amplifier) without one. + fn register_create_identity( + &self, + terminal_id: &str, + mode: &str, + resume_session_id: Option<&str>, + cwd: Option<&str>, + create_request_id: Option<&str>, + ); + /// Exit-side hygiene (load-bearing ledger A2): mirrors the WS pane + /// EXIT hook (terminal.rs:1334-1342) EXACTLY — retire the identity row + /// (in-memory flag flip) and delete any pending marker. Deliberately + /// does NOT touch the ledger binding: `retire_closed` is the + /// explicit-user-close trigger only ("P1.8 trigger (e)", the WS kill + /// command path, terminal.rs:3849-3868), never the natural-exit path, + /// and the Bound-after-natural-exit ledger row is load-bearing — + /// `auto_resume::pre_respawn_guard` reads a still-Bound row as "pane + /// still wants this session" (auto_resume.rs:445-450) and the recovery + /// inventory keys on `RetiredReason::Closed` meaning deliberate close + /// (recovery_inventory.rs:299-301). Both A2 hazards are closed by the + /// identity-row retire alone: the session directory joins identity + /// rows for liveness (session_directory.rs:716-766, and the rename + /// cascade with it, sessions.rs:167-187), and the claude drain's no-op + /// arm checks `current.retired` (claude_signal.rs:253-342), so a late + /// new-id SessionStart cannot durably rebind a dead pane. Idempotent; + /// harmless no-op for terminals with no identity row. Called from the + /// pane exit hook for ALL non-shell creates. SYNC ON PURPOSE: the exit + /// hook is a plain FnOnce on the PTY reader thread — blocking IO is + /// safe there, .await is impossible (mirrors the WS exit hook, + /// terminal.rs:1334-1342). + fn retire_pane_identity(&self, terminal_id: &str); +} + impl TerminalRegistry { pub fn new() -> Self { Self { diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 52cf6588d..84e85f09b 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -36,6 +36,7 @@ pub mod invariants; pub mod opencode_association; pub mod opencode_signal; pub mod origin; +pub mod pane_identity_binder; pub mod pane_ledger; pub mod reconcile; pub mod reconcile_freshagent; diff --git a/crates/freshell-ws/src/pane_identity_binder.rs b/crates/freshell-ws/src/pane_identity_binder.rs new file mode 100644 index 000000000..e6423897b --- /dev/null +++ b/crates/freshell-ws/src/pane_identity_binder.rs @@ -0,0 +1,309 @@ +//! Write-side pane-identity seam for the REST spawn pipeline (kata hbsa). +//! +//! `freshell-freshagent` cannot depend on `freshell-ws` (circular), so it +//! cannot write `TerminalIdentityRegistry` rows or `PaneLedger` bindings +//! directly — the exact gap that left REST claude panes un-resumable and +//! invisible to A13. This impl mirrors the WS create path's identity writes +//! (`terminal.rs` PIN2_CLAUDE_PRE_SPAWN_BINDING block, its failure-delete +//! twin, and the post-spawn identity/binding/pending block) behind the +//! `freshell_terminal::registry::PaneIdentityBinder` trait, wired into +//! `FreshAgentState` by `freshell-server::main` (the `SessionIdentityLookup` +//! precedent, read-side twin). +//! +//! Failure policy: ledger writes are best-effort — warn on the +//! `freshell_ws::invariants` target and proceed; a create is never blocked +//! by durability degradation. (The WS rung additionally broadcasts +//! `DurabilityDegraded` via `surface_write_failure`, which needs `&WsState`; +//! this seam has no `WsState`, and log-only is strictly better than the +//! nothing-at-all the REST lane wrote before.) + +use std::sync::Arc; + +use crate::identity::TerminalIdentityRegistry; +use crate::pane_ledger::{BindingWrite, PaneLedger}; +use crate::terminal::now_ms; + +pub struct LedgerPaneIdentityBinder { + identity: TerminalIdentityRegistry, + ledger: Arc, +} + +impl LedgerPaneIdentityBinder { + pub fn new(identity: TerminalIdentityRegistry, ledger: Arc) -> Self { + Self { identity, ledger } + } + + fn warn_write_failure(terminal_id: &str, what: &str, err: &std::io::Error) { + tracing::warn!( + target: "freshell_ws::invariants", + terminal_id = %terminal_id, + error = %err, + "pane_ledger_write_failed: {what} (REST rung; create proceeds, durability degraded)" + ); + } +} + +/// `PaneLedger`/`TerminalIdentityRegistry` internals are not `Debug`; the +/// trait's supertrait (matching `SessionIdentityLookup`) only needs the +/// object to be printable in `Debug`-derived state. +impl std::fmt::Debug for LedgerPaneIdentityBinder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("LedgerPaneIdentityBinder") + } +} + +// The binder itself is plain sync: every PaneLedger writer is a sync +// `fn -> io::Result<()>` (pane_ledger.rs) and the identity registry is a +// sync RwLock (identity.rs). Async REST call sites hop the ledger-touching +// calls through spawn_blocking (Task 5), mirroring the WS create path's own +// idiom (terminal.rs PIN2_CLAUDE_PRE_SPAWN_BINDING block); the exit hook +// calls retire_pane_identity inline on the PTY reader thread, mirroring the +// WS exit hook's inline-sync retire (terminal.rs build_pty_exit_hook). +impl freshell_terminal::registry::PaneIdentityBinder for LedgerPaneIdentityBinder { + fn record_prespawn_claude_binding( + &self, + session_id: &str, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + create_request_id: Option<&str>, + ) { + if let Err(err) = self.ledger.record_binding(&BindingWrite { + provider: "claude", + session_id, + terminal_id, + mode, + cwd, + create_request_id, + now_ms: now_ms(), + }) { + Self::warn_write_failure(terminal_id, "pre-spawn claude binding (PIN 2)", &err); + } + } + + fn delete_prespawn_claude_binding(&self, session_id: &str) { + if let Err(err) = self.ledger.delete_binding("claude", session_id) { + Self::warn_write_failure("(spawn-failed)", "pre-spawn binding failure-delete", &err); + } + } + + fn register_create_identity( + &self, + terminal_id: &str, + mode: &str, + resume_session_id: Option<&str>, + cwd: Option<&str>, + create_request_id: Option<&str>, + ) { + // Mirrors terminal.rs's post-spawn block (the DEV-0008 create-time + // slice): identity row + binding for any non-shell create carrying a + // session id (terminal_meta_record_for_create semantics -- + // identity.upsert + record_binding), pending marker for the + // identity-in-flight providers (the MARKER_MODES/record_pending arm), + // substituting self.identity / self.ledger and warn_write_failure for + // surface_write_failure, and dropping the spawn_blocking wrappers + // (the async hop lives at the Task 5 call sites, not here). + if mode == "shell" { + return; + } + if let Some(session_id) = resume_session_id.filter(|s| !s.is_empty()) { + self.identity + .upsert(terminal_id, Some(mode), Some(session_id), cwd, now_ms()); + if let Err(err) = self.ledger.record_binding(&BindingWrite { + provider: mode, // keep exactly what the WS block does (provider = mode) + session_id, + terminal_id, + mode, + cwd, + create_request_id, + now_ms: now_ms(), + }) { + Self::warn_write_failure(terminal_id, "post-spawn identity binding", &err); + } + } else if crate::terminal::MARKER_MODES.contains(&mode) { + // The pending-marker arm (terminal.rs:2523-2540): identity-bearing + // pane whose identity is still in flight (fresh codex/opencode/ + // amplifier -- trigger d): a durable pending marker from spawn + // until resolution deletes it (binding-first order). + if let Err(err) = self.ledger.record_pending(terminal_id, mode, cwd, now_ms()) { + Self::warn_write_failure(terminal_id, "spawn-time pending marker", &err); + } + } + } + + fn retire_pane_identity(&self, terminal_id: &str) { + // The WS pane EXIT hook ONLY (terminal.rs:1334-1342) -- identity + // retire + pending-marker delete, both called directly (sync). Do + // NOT port `retire_closed` from the kill path (handle_kill): that is + // the explicit-user-close trigger (P1.8 trigger (e)); a natural exit + // or crash must leave the ledger binding Bound, exactly like a WS + // pane, so auto_resume::pre_respawn_guard (auto_resume.rs:445-450) + // and the recovery inventory (RetiredReason::Closed keying, + // recovery_inventory.rs:299-301) still read the row correctly. + // This method MUST stay runtime-free: production calls it from the + // PTY reader thread's exit hook, where blocking IO is safe and tokio + // does not exist. The identity retire is an in-memory flag flip; + // this method changes NO drain logic (the #573/#578-pinned drains + // stay untouched). + self.identity.retire(terminal_id); + if let Err(err) = self.ledger.delete_pending(terminal_id) { + Self::warn_write_failure(terminal_id, "pending-marker delete on exit", &err); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pane_ledger::{PaneLedger, RowState}; + use std::sync::Arc; + + fn temp_root(label: &str) -> std::path::PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "pane-identity-binder-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp root"); + dir + } + + #[allow(clippy::type_complexity)] + fn binder( + label: &str, + ) -> ( + LedgerPaneIdentityBinder, + Arc, + crate::identity::TerminalIdentityRegistry, + std::path::PathBuf, + ) { + let dir = temp_root(label); + let ledger = Arc::new(PaneLedger::new(Some(dir.clone()))); + let identity = crate::identity::TerminalIdentityRegistry::default(); + ( + LedgerPaneIdentityBinder::new(identity.clone(), Arc::clone(&ledger)), + ledger, + identity, + dir, + ) + } + + const SID: &str = "29a53649-1111-4222-8333-444455556666"; + + #[test] + fn prespawn_binding_writes_a_bound_claude_row_and_delete_removes_it() { + use freshell_terminal::registry::PaneIdentityBinder as _; + let (b, ledger, _identity, dir) = binder("prespawn"); + b.record_prespawn_claude_binding(SID, "t-rest-1", "claude", Some("/tmp"), Some("req-1")); + let row = ledger + .load_binding("claude", SID) + .expect("pre-spawn row exists (PIN 2)"); + assert_eq!(row.live_terminal_id.as_deref(), Some("t-rest-1")); + assert_eq!(row.state, RowState::Bound); + + b.delete_prespawn_claude_binding(SID); + assert!( + ledger.load_binding("claude", SID).is_none(), + "failure-delete removes the row" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn register_create_identity_writes_identity_row_and_binding() { + use freshell_terminal::registry::PaneIdentityBinder as _; + let (b, ledger, identity, dir) = binder("register"); + b.register_create_identity("t-rest-2", "claude", Some(SID), Some("/tmp"), Some("req-2")); + let row = identity + .get("t-rest-2") + .expect("identity row (the A13/signal-drain prerequisite)"); + assert_eq!(row.provider.as_deref(), Some("claude")); + assert_eq!(row.session_id.as_deref(), Some(SID)); + let binding = ledger + .load_binding("claude", SID) + .expect("post-spawn binding row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some("t-rest-2")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn register_create_identity_skips_shell_and_marks_pending_for_marker_modes() { + use freshell_terminal::registry::PaneIdentityBinder as _; + let (b, ledger, identity, dir) = binder("markers"); + // shell: nothing at all + b.register_create_identity("t-shell", "shell", None, None, None); + assert!(identity.get("t-shell").is_none()); + assert!(ledger.pending_for_terminal("t-shell").is_none()); + // codex without an id: pending marker (locator lane resolves later), + // exactly the WS MARKER_MODES arm (terminal.rs:2523-2540). + b.register_create_identity("t-codex", "codex", None, Some("/tmp"), Some("req-3")); + assert!( + identity.get("t-codex").is_none(), + "no premature identity row" + ); + let marker = ledger + .pending_for_terminal("t-codex") + .expect("pending marker written"); + assert_eq!(marker.mode, "codex"); + assert_eq!(marker.cwd.as_deref(), Some("/tmp")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn ledger_write_failure_never_panics_the_create() { + use freshell_terminal::registry::PaneIdentityBinder as _; + // A disabled ledger (or an unwritable root) must degrade to a warn, + // never an Err/panic — failure never blocks the create. + let identity = crate::identity::TerminalIdentityRegistry::default(); + let b = LedgerPaneIdentityBinder::new(identity.clone(), Arc::new(PaneLedger::disabled())); + b.record_prespawn_claude_binding(SID, "t-x", "claude", None, None); + b.register_create_identity("t-x", "claude", Some(SID), None, None); + // identity row still lands even when durability is degraded: + assert!(identity.get("t-x").is_some()); + } + + #[test] + fn retire_pane_identity_retires_row_and_clears_pending() { + use freshell_terminal::registry::PaneIdentityBinder as _; + // Ledger A2: exit-side hygiene — retired rows must stop looking live. + // Sync test on purpose: retire MUST be callable with no runtime, + // because production calls it from the PTY reader thread's exit hook. + let (b, ledger, identity, dir) = binder("retire"); + b.register_create_identity("t-rest-4", "claude", Some(SID), Some("/tmp"), None); + b.retire_pane_identity("t-rest-4"); + // Retired == invisible to live lookups, exactly what the WS pane + // EXIT hook produces: the live find_by_session no longer returns the + // terminal, while the retired-inclusive get() still does. + assert!( + identity.find_by_session("claude", SID).is_none(), + "retired row is not a live owner" + ); + let row = identity.get("t-rest-4").expect("identity survives retire"); + assert!(row.retired, "exit hook flips the retired flag"); + // NATURAL-EXIT contract pin: the durable ledger binding must STAY + // Bound — retire_closed is the explicit-kill trigger + // (terminal.rs handle_kill), never the exit hook's. A still-Bound row + // after natural exit is load-bearing for + // auto_resume::pre_respawn_guard and the recovery inventory's + // RetiredReason::Closed keying. + let binding = ledger + .load_binding("claude", SID) + .expect("natural exit must NOT retire the ledger binding"); + assert_eq!(binding.state, RowState::Bound); + assert!(binding.retired_reason.is_none()); + // And the pending-marker delete arm: register a marker-mode pane, + // retire it, assert its pending marker is gone. + b.register_create_identity("t-codex-r", "codex", None, Some("/tmp"), None); + assert!( + ledger.pending_for_terminal("t-codex-r").is_some(), + "marker present before retire" + ); + b.retire_pane_identity("t-codex-r"); + assert!( + ledger.pending_for_terminal("t-codex-r").is_none(), + "exit hook deletes the pending marker" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 07ece6cec..1621241ba 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -106,7 +106,7 @@ pub(crate) fn now_ms() -> i64 { /// by design; kimi/gemini/custom extension modes have no resolver, so a /// marker for them could never resolve and would only leak until the TTL /// sweep. Every mode listed here MUST have a resolution hook (Tasks 8-9). -const MARKER_MODES: [&str; 3] = ["codex", "opencode", "amplifier"]; +pub(crate) const MARKER_MODES: [&str; 3] = ["codex", "opencode", "amplifier"]; /// Map the protocol `shell` enum to the platform `ShellType`. fn map_shell(shell: Shell) -> ShellType { From 1bc31c5fb20f63cddc3ab5e2b3d3863868605078 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:01:54 -0700 Subject: [PATCH 11/15] feat(freshagent): REST creates write identity rows and ledger bindings via PaneIdentityBinder (kata hbsa) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-freshagent/src/lib.rs | 20 + .../freshell-freshagent/src/terminal_tabs.rs | 342 +++++++++++++++++- crates/freshell-server/src/main.rs | 36 +- 3 files changed, 378 insertions(+), 20 deletions(-) diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ca83086bd..2235565e1 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -146,6 +146,12 @@ pub struct FreshAgentState { /// narrows the guard to the registry-row arm. pub(crate) session_identity: Option>, + /// Write-side pane-identity seam (kata hbsa): lets the REST spawn + /// pipeline write TerminalIdentityRegistry rows and PaneLedger bindings + /// across the freshagent->ws crate boundary. Read-side twin: + /// `session_identity`. `None` (tests without identity concerns) = the + /// legacy no-write behavior. + pub(crate) pane_identity: Option>, /// paneId -> terminal pane record (Slice 1 `mode:'shell'` terminals /// created via `POST /api/tabs`). Disjoint from `panes` (fresh-agent-only) /// and `content_panes` (browser/editor) -- a pane id appears in exactly @@ -275,6 +281,7 @@ impl FreshAgentState { sessions_revision: Arc::new(AtomicI64::new(0)), terminal_registry: None, session_identity: None, + pane_identity: None, terminal_panes: Arc::new(Mutex::new(HashMap::new())), content_panes: Arc::new(Mutex::new(HashMap::new())), tabs: Arc::new(Mutex::new(HashMap::new())), @@ -460,6 +467,19 @@ impl FreshAgentState { self } + /// Write-side twin of [`Self::with_session_identity`] (kata hbsa): wire + /// in the pane-identity binder so the REST spawn pipeline + /// (`spawn_terminal_pane` -> `settle_gated_create`) can write identity + /// rows and durable ledger bindings exactly like the WS create path. + /// Unwired (`None`), REST creates keep the legacy no-write behavior. + pub fn with_pane_identity_binder( + mut self, + binder: Arc, + ) -> Self { + self.pane_identity = Some(binder); + self + } + /// SESSION-09 fix-forward: replace this state's own `sessions_revision` /// counter with a SHARED one -- in production, `freshell-server` wires /// this to the SAME `Arc` as `freshell_ws::WsState::sessions_revision` diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index de8d4bcd7..74b1ac3ab 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -1141,6 +1141,7 @@ pub(crate) async fn spawn_terminal_pane( resume_session_id, accepted_session_ref, claude_fresh_prealloc, + pane_identity: state.pane_identity.clone(), create_request_id, session_ref_lease, registry, @@ -1182,6 +1183,12 @@ struct GatedSettleInputs { /// spawn-failure delete (Task 5 call sites) are BOTH gated on this exact /// flag, never on `mode == "claude"`. claude_fresh_prealloc: bool, + /// Write-side pane-identity seam (kata hbsa, Task 5): `Some` in + /// production (wired by `freshell-server::main` via + /// [`FreshAgentState::with_pane_identity_binder`]); `None` (tests + /// without identity concerns) keeps the legacy no-write behavior — + /// every call site below is `if let Some`-gated. + pane_identity: Option>, create_request_id: String, session_ref_lease: Option, registry: freshell_terminal::TerminalRegistry, @@ -1221,6 +1228,7 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Result = { let tid = terminal_id.clone(); let cleanup_mode = mode.clone(); let cleanup_cwd = mcp_cwd.clone(); let registry_for_exit = registry.clone(); let opencode_locator = state.opencode_locator.clone(); + // Owned binder clone for the exit-side retire (see the block comment + // above): shell panes are never session-identified by design. + let exit_binder: Option< + std::sync::Arc, + > = if mode == "shell" { + None + } else { + pane_identity.clone() + }; // Launcher-assigned amplifier identity (Task 11, REST twin of the WS // Task 10 hook): only a stub THIS create wrote (`created == true`) is // ours to GC on exit; found/existing sessions are never touched. @@ -1481,6 +1504,12 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Result Result rollback // is a no-op by construction, EXCEPT the MCP config file(s) // `generate_mcp_injection` may already have written -- clean those up @@ -1645,6 +1716,27 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result (FreshAgentState, freshell_terminal::TerminalRegistry) { + let argv_file = unique_argv_file("binder-broken"); + let mut spec = claude_prealloc_recording_cli_spec(&argv_file); + spec.default_cmd = "/nonexistent/freshell-task5-missing-claude".to_string(); + let state = state_with_registry().with_cli_commands(std::sync::Arc::new(vec![spec])); + let registry = state.terminal_registry.clone().expect("registry wired"); + (state, registry) + } + /// Subscribe BEFORE the POST — the `:3383` sibling's broadcast-capture /// idiom (`state.broadcast_tx.subscribe()`). fn subscribe_broadcast_frames( @@ -3749,6 +3855,224 @@ mod tests { let _ = std::fs::remove_file(&_capture); } + // ── kata hbsa Task 5: PaneIdentityBinder threading through the REST rung ─ + + /// Recording fake for the write-side identity seam: appends one string + /// per binder call so the tests can assert call ORDER (PIN 2: durability + /// before observability) as well as presence/absence. + #[derive(Default, Debug)] + struct RecordingBinder { + events: std::sync::Mutex>, + } + + impl RecordingBinder { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl freshell_terminal::registry::PaneIdentityBinder for RecordingBinder { + fn record_prespawn_claude_binding( + &self, + session_id: &str, + terminal_id: &str, + _mode: &str, + _cwd: Option<&str>, + _create_request_id: Option<&str>, + ) { + self.events + .lock() + .unwrap() + .push(format!("prespawn:{terminal_id}:{session_id}")); + } + fn delete_prespawn_claude_binding(&self, session_id: &str) { + self.events + .lock() + .unwrap() + .push(format!("delete:{session_id}")); + } + fn register_create_identity( + &self, + terminal_id: &str, + mode: &str, + resume_session_id: Option<&str>, + _cwd: Option<&str>, + _create_request_id: Option<&str>, + ) { + self.events.lock().unwrap().push(format!( + "register:{terminal_id}:{mode}:{}", + resume_session_id.unwrap_or("-") + )); + } + fn retire_pane_identity(&self, terminal_id: &str) { + self.events + .lock() + .unwrap() + .push(format!("retire:{terminal_id}")); + } + } + + #[tokio::test] + async fn fresh_claude_rest_create_drives_binder_prespawn_then_register() { + // kata hbsa P1: PIN 2 ordering on the REST rung — durable pre-spawn + // binding, then spawn, then identity registration. + let binder = std::sync::Arc::new(RecordingBinder::default()); + let (state, registry, _capture) = state_with_claude_capture_spec("binder-prespawn"); + let state = state.with_pane_identity_binder(binder.clone()); + + let (status, body) = post( + app(state), + "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + // The REST body carries only ids; the minted sid comes from the + // registry row (same read as Task 2's assertion 2). + let tid = body["data"]["terminalId"].as_str().unwrap().to_string(); + let sid = registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == tid) + .and_then(|r| r.resume_session_id) + .expect("minted id in the registry row"); + + let events = binder.events(); + let prespawn = events + .iter() + .position(|e| e == &format!("prespawn:{tid}:{sid}")) + .unwrap_or_else(|| panic!("prespawn event missing: {events:?}")); + let register = events + .iter() + .position(|e| e == &format!("register:{tid}:claude:{sid}")) + .unwrap_or_else(|| panic!("register event missing: {events:?}")); + assert!( + prespawn < register, + "PIN 2: durability before registration: {events:?}" + ); + assert!( + !events.iter().any(|e| e.starts_with("delete:")), + "no failure-delete on success" + ); + + registry.kill(&tid); + let _ = std::fs::remove_file(&_capture); + } + + #[tokio::test] + async fn resume_claude_rest_create_registers_identity_without_prespawn_write() { + // eaa25b7d scoping on the REST rung: a RESUME create never writes the + // pre-spawn row (it belongs to the prior epoch) but DOES register + // identity post-spawn — this closes the resume-direction half of the + // gap (REST resumes previously died at restart: pane_ledger_restore.rs). + let binder = std::sync::Arc::new(RecordingBinder::default()); + let (state, registry, _capture) = state_with_claude_capture_spec("binder-resume"); + let state = state.with_pane_identity_binder(binder.clone()); + + const S: &str = "29a53649-2222-4333-8444-555566667777"; + // Mirror the request shape of the existing passing with-identity create + // test (create_tab_with_identity_or_shell_mode_does_not_warn_invariant). + let (status, body) = post( + app(state), + "/api/tabs", + serde_json::json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": {"provider": "claude", "sessionId": S}, + }), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + let tid = body["data"]["terminalId"].as_str().unwrap().to_string(); + + let events = binder.events(); + assert!( + !events.iter().any(|e| e.starts_with("prespawn:")), + "resume creates must not write the pre-spawn row (eaa25b7d): {events:?}" + ); + assert!( + events.contains(&format!("register:{tid}:claude:{S}")), + "{events:?}" + ); + + registry.kill(&tid); + let _ = std::fs::remove_file(&_capture); + } + + #[tokio::test] + async fn failed_fresh_claude_spawn_deletes_its_prespawn_binding() { + // eaa25b7d symmetry: the failure-delete fires with the SAME gate as the + // write, for the id THIS create minted. + let binder = std::sync::Arc::new(RecordingBinder::default()); + // A spec whose command cannot spawn: point default_cmd at a + // nonexistent path (no env_var), same spec shape as the capture spec. + let (state, _registry) = state_with_broken_claude_spec(); + let state = state.with_pane_identity_binder(binder.clone()); + + let (status, _body) = post( + app(state), + "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), + true, + ) + .await; + assert!(!status.is_success(), "spawn must fail"); + + let events = binder.events(); + let prespawn_sid = events + .iter() + .find_map(|e| { + e.strip_prefix("prespawn:") + .and_then(|rest| rest.split(':').nth(1)) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("prespawn happened before the spawn attempt: {events:?}")); + assert!( + events.contains(&format!("delete:{prespawn_sid}")), + "failure-delete for the minted id: {events:?}" + ); + assert!( + !events.iter().any(|e| e.starts_with("register:")), + "{events:?}" + ); + } + + #[tokio::test] + async fn rest_pane_exit_retires_identity_via_binder() { + // Ledger A2: dead REST panes must not keep live-looking identity rows. + let binder = std::sync::Arc::new(RecordingBinder::default()); + let (state, registry, _capture) = state_with_claude_capture_spec("binder-exit"); + let state = state.with_pane_identity_binder(binder.clone()); + + let (status, body) = post( + app(state), + "/api/tabs", + serde_json::json!({"mode":"claude","cwd": std::env::temp_dir().to_string_lossy()}), + true, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + let tid = body["data"]["terminalId"].as_str().unwrap().to_string(); + + registry.kill(&tid); + // The exit hook runs asynchronously — poll with a bounded deadline. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if binder.events().contains(&format!("retire:{tid}")) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "exit hook never retired the pane: {:?}", + binder.events() + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let _ = std::fs::remove_file(&_capture); + } + #[tokio::test] async fn create_codex_tab_rejects_raw_resume_session_id_without_session_ref() { let argv_file = unique_argv_file("codex-reject"); diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index d5ff3048d..3506b39da 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -281,9 +281,33 @@ async fn main() -> ExitCode { // live-session guard can consume it through the `SessionIdentityLookup` // seam (cheap-clone handle; `WsState` keeps using this same binding). let terminal_identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + // P1.8: the pane-identity ledger (spec §4.2). Root resolved ONCE here; + // the module itself never reads env vars. No home => disabled no-op, + // same policy as tabs-snapshots. `new_locked` = the single-writer + // guard (V2.md): exclusive flock on /lock, ConfigLock pattern — + // a second server on the same home comes up with a DISABLED ledger and + // a loud ERROR instead of two writers corrupting one store. Hoisted + // above the fresh-agent builder chain (kata hbsa Task 5, ledger A8): + // it depends only on `home`, and the REST spawn pipeline's + // `PaneIdentityBinder` below must share THIS instance with `ws_state`. + let pane_ledger = std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new_locked( + home.as_ref() + .map(|h| h.join(".freshell").join("pane-ledger")), + )); let fresh_agent_state = fresh_agent_state .with_terminal_registry(registry.clone()) - .with_session_identity(std::sync::Arc::new(terminal_identity.clone())); + .with_session_identity(std::sync::Arc::new(terminal_identity.clone())) + // Write-side twin (kata hbsa Task 5): REST creates write identity + // rows and durable ledger bindings through the SAME + // `TerminalIdentityRegistry` + `PaneLedger` instances `ws_state` + // uses below — REST-written rows must be visible to the WS + // guard/drain and vice versa. + .with_pane_identity_binder(std::sync::Arc::new( + freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new( + terminal_identity.clone(), + std::sync::Arc::clone(&pane_ledger), + ), + )); // TERM-11 fix: honor `settings.safety.autoKillIdleMinutes` at boot (the // Rust registry previously never read it at all, so a config that raised // or lowered it from the default had no effect). See @@ -518,16 +542,6 @@ async fn main() -> ExitCode { ); // Shutdown latch shared with shutdown_signal (Task 7 wires the setter). let shutdown_started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - // P1.8: the pane-identity ledger (spec §4.2). Root resolved ONCE here; - // the module itself never reads env vars. No home => disabled no-op, - // same policy as tabs-snapshots. `new_locked` = the single-writer - // guard (V2.md): exclusive flock on /lock, ConfigLock pattern — - // a second server on the same home comes up with a DISABLED ledger and - // a loud ERROR instead of two writers corrupting one store. - let pane_ledger = std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new_locked( - home.as_ref() - .map(|h| h.join(".freshell").join("pane-ledger")), - )); // P1.13: inject the ledger-backed identity sink into the fresh-agent // states (constructed earlier, before the ledger exists — the // post-construction setter exists precisely for this ordering). All From c76296a7682aef4514dfc17f1fa1d25327e0293f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:12:40 -0700 Subject: [PATCH 12/15] fix(freshagent): log pane-identity binder task join errors instead of swallowing them --- .../freshell-freshagent/src/terminal_tabs.rs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 74b1ac3ab..ee2ad8773 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -1559,10 +1559,14 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Result rollback @@ -1731,10 +1739,14 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Date: Wed, 29 Jul 2026 20:33:56 -0700 Subject: [PATCH 13/15] =?UTF-8?q?test(ws):=20e2e=20pins=20=E2=80=94=20REST?= =?UTF-8?q?=20claude=20identity=20durability,=20A13=20refusal=20of=20REST-?= =?UTF-8?q?live=20sessions,=20signal=20consumption=20(kata=20hbsa)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../freshell-ws/tests/pane_ledger_restore.rs | 22 +- .../freshell-ws/tests/rest_claude_identity.rs | 533 ++++++++++++++++++ 2 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 crates/freshell-ws/tests/rest_claude_identity.rs diff --git a/crates/freshell-ws/tests/pane_ledger_restore.rs b/crates/freshell-ws/tests/pane_ledger_restore.rs index 1d4cd2280..5b783f85a 100644 --- a/crates/freshell-ws/tests/pane_ledger_restore.rs +++ b/crates/freshell-ws/tests/pane_ledger_restore.rs @@ -234,12 +234,22 @@ async fn claude_restore_still_fails_loud_when_the_ledger_row_was_closed() { #[tokio::test(flavor = "multi_thread")] async fn claude_restore_is_refused_while_a_rest_shaped_live_claude_owns_the_session() { - // A13 SAFETY red test (V6.md): a claude resumed via the freshagent REST - // API is invisible to identity.find_by_session (never upserted) AND to - // createRequestId lineage (REST mints none) — its ONLY footprint is a - // registry row {mode:"claude", resume_session_id:S, status:Running}. - // The ledger rung's live-guard must scan registry rows, or it would - // green-light a second live claude on S. + // A13 SAFETY red test (V6.md): a claude whose ONLY footprint is a + // registry row {mode:"claude", resume_session_id:S, status:Running} — + // no identity row, no createRequestId lineage. The ledger rung's + // live-guard must scan registry rows, or it would green-light a second + // live claude on S. + // + // HISTORY (kata hbsa): this was the REST lane's real shape — REST + // creates never upserted identity.find_by_session and minted no + // createRequestId. Since Tasks 2+5 the REST lane mints a preallocated + // id AND writes identity rows + durable ledger bindings through + // `PaneIdentityBinder` (see tests/rest_claude_identity.rs), so a live + // REST claude is no longer invisible to the identity arm. The + // hand-built footprint below (register_headless, no identity upsert) + // now models the DEGRADED case — an identity/ledger write failure, or + // a pane from an older server generation — which the registry-row scan + // must still catch. Still a valid pin; keep it. let dir = unique_ledger_dir("ladder-rest-live"); use futures_util::SinkExt; let (url, registry, server_ledger) = diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs new file mode 100644 index 000000000..2925190c6 --- /dev/null +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -0,0 +1,533 @@ +#![cfg(unix)] +//! kata hbsa regression suite: REST-created claude panes carry full session +//! identity (Required Outcomes P1/2/4). Isolation rules (AGENTS.md + the +//! live 3002 server): temp-dir signal root via ClaudeSignalWatcher::new, +//! temp-dir lock-free PaneLedger::new, synchronous drains only. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use freshell_ws::WsState; + +struct Harness { + base_url: String, // http://{addr} + ws: common::TestWs, // connected + hello'd + registry: freshell_terminal::TerminalRegistry, + ws_state: WsState, + ledger: Arc, + ledger_dir: std::path::PathBuf, + signal_root: std::path::PathBuf, +} + +/// Unique temp dir (pid + nanos, per pane_ledger_restore.rs:13-24). +fn unique_temp_dir(label: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "rest-claude-identity-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// The combined production shape (PORT, not new design): the body of +/// `rest_ws_shared_gate.rs`'s `spawn_combined_server` with +/// `common::spawn_server_with_ledger`'s WsState-with-real-ledger +/// construction folded in, returning the extra handles named in `Harness`. +/// ONE `TerminalIdentityRegistry` and ONE `Arc` shared by +/// `WsState` and the `LedgerPaneIdentityBinder` handed to `FreshAgentState` +/// — mirroring `freshell-server/src/main.rs`'s wiring. +async fn spawn_merged_server() -> Harness { + let _ = common::isolate_amplifier_home(); + let ledger_dir = unique_temp_dir("ledger"); + let signal_root = unique_temp_dir("signals"); + + let auth_token = Arc::new(common::AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = Arc::new( + serde_json::from_value(common::test_settings_value()).expect("valid settings fixture"), + ); + let registry = freshell_terminal::TerminalRegistry::new(); + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + ledger_dir.clone(), + ))); + let cli_commands = Arc::new(vec![common::sleeper_cli_spec("claude")]); + + let state = WsState { + pane_ledger: Arc::clone(&ledger), + identity: identity.clone(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-test".to_string()), + boot_id: Arc::new("boot-test".to_string()), + settings, + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: registry.clone(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::clone(&cli_commands), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + + // The REST door: same auth token + broadcast bus + terminal registry as + // the WS door, with the read-side identity lookup AND the write-side + // pane-identity binder wired over the SAME identity/ledger instances + // (main.rs:297-310's shape). + let fresh_agent_state = freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ) + .with_cli_commands(Arc::clone(&cli_commands)) + .with_terminal_registry(registry.clone()) + .with_session_identity(Arc::new(identity.clone())) + .with_pane_identity_binder(Arc::new( + freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new( + identity.clone(), + Arc::clone(&ledger), + ), + )); + + let app = + freshell_ws::router(state.clone()).merge(freshell_freshagent::router(fresh_agent_state)); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let ws_url = format!("ws://{addr}/ws"); + let (ws, _inventory) = common::connect_and_capture_inventory(&ws_url).await; + + Harness { + base_url: format!("http://{addr}"), + ws, + registry, + ws_state: state, + ledger, + ledger_dir, + signal_root, + } +} + +/// A parsed minimal HTTP response. +struct RestResponse { + status: u16, + body: String, + json: serde_json::Value, +} + +/// Minimal hand-rolled HTTP/1.1 POST over a raw `TcpStream` (ported verbatim +/// from `rest_ws_shared_gate.rs::reqwest_like_post`, body parameterized). +async fn raw_post_tabs(base_url: &str, body_json: &serde_json::Value) -> RestResponse { + let host = base_url + .strip_prefix("http://") + .expect("base_url is http://{addr}"); + let body = body_json.to_string(); + let request = format!( + "POST /api/tabs HTTP/1.1\r\n\ + Host: {host}\r\n\ + x-auth-token: {token}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {len}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + token = common::AUTH_TOKEN, + len = body.len(), + ); + + let mut stream = TcpStream::connect(host).await.expect("connect to server"); + stream + .write_all(request.as_bytes()) + .await + .expect("write HTTP request"); + let mut raw = Vec::new(); + tokio::time::timeout(Duration::from_secs(15), stream.read_to_end(&mut raw)) + .await + .expect("HTTP response within deadline") + .expect("read HTTP response"); + let text = String::from_utf8(raw).expect("utf8 HTTP response"); + + let (head, response_body) = text + .split_once("\r\n\r\n") + .expect("HTTP header/body separator"); + let status: u16 = head + .split_whitespace() + .nth(1) + .expect("status code in status line") + .parse() + .expect("numeric status code"); + let json = serde_json::from_str(response_body.trim()).unwrap_or(serde_json::Value::Null); + RestResponse { + status, + body: response_body.to_string(), + json, + } +} + +/// POST /api/tabs with the given body; returns `(terminal_id, session_id)`: +/// terminal_id from the response body's `data.terminalId` (the REST HTTP body +/// carries ONLY {tabId, paneId, terminalId} — paneContent.sessionRef travels +/// on the broadcast ui.command frame, not the body), session_id from the +/// harness's registry row (`identity_probe_rows` -> `resume_session_id`). +/// Panics with the full body / row set on any miss. +async fn rest_create_claude_with_body(h: &Harness, body: serde_json::Value) -> (String, String) { + let resp = raw_post_tabs(&h.base_url, &body).await; + assert_eq!(resp.status, 200, "REST create failed: {}", resp.body); + let tid = resp.json["data"]["terminalId"] + .as_str() + .unwrap_or_else(|| panic!("REST body carries data.terminalId: {}", resp.body)) + .to_string(); + let rows = h.registry.identity_probe_rows(); + let sid = rows + .iter() + .find(|r| r.terminal_id == tid) + .unwrap_or_else(|| panic!("registry row for {tid} missing: {rows:?}")) + .resume_session_id + .clone() + .unwrap_or_else(|| panic!("registry row for {tid} has no resume_session_id: {rows:?}")); + (tid, sid) +} + +/// Fresh REST claude create: POST /api/tabs {"mode":"claude","cwd":}. +async fn rest_create_claude(h: &Harness) -> (String, String) { + rest_create_claude_with_body( + h, + json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + }), + ) + .await +} + +/// [`rest_create_claude`] with a `sessionRef` field added to the body — the +/// REST resume direction. Same raw-POST helper, same return shape. +async fn rest_create_claude_with_session_ref(h: &Harness, sid: &str) -> (String, String) { + rest_create_claude_with_body( + h, + json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": { "provider": "claude", "sessionId": sid }, + }), + ) + .await +} + +/// Read frames until either an `error` or a `terminal.created` correlated to +/// `request_id` arrives. Panics if a `terminal.created` for the request shows +/// up — that IS the duplicate spawn the guard forbids. Ported verbatim from +/// `live_session_ref_guard.rs`. +async fn expect_refusal_for(ws: &mut common::TestWs, request_id: &str) -> serde_json::Value { + for _ in 0..20u8 { + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("frame within timeout") + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + match value["type"].as_str() { + Some("terminal.created") if value["requestId"] == json!(request_id) => { + panic!("duplicate spawn: create must be refused, got {value}"); + } + Some("error") if value["requestId"] == json!(request_id) => { + return value; + } + _ => {} + } + } + } + panic!("no error frame for {request_id} within 20 messages"); +} + +/// Poll `cond` every 50ms until it holds or `deadline` elapses (panic). +async fn wait_until(deadline: Duration, mut cond: impl FnMut() -> bool, what: &str) { + let end = tokio::time::Instant::now() + deadline; + while tokio::time::Instant::now() < end { + if cond() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("timed out waiting for {what}"); +} + +fn cleanup(h: &Harness) { + std::fs::remove_dir_all(&h.ledger_dir).ok(); + std::fs::remove_dir_all(&h.signal_root).ok(); +} + +/// 4a — a REST-created claude pane has a USABLE RESUME IDENTITY that does +/// not depend on any signal file existing: preallocated id in the registry +/// row (readable at create time — the rung-0 feed), identity row, and a +/// durable Bound ledger binding — with the signal directory EMPTY throughout. +#[tokio::test(flavor = "multi_thread")] +async fn rest_created_claude_pane_has_durable_resume_identity_without_signals() { + let h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + uuid::Uuid::parse_str(&sid).expect("canonical UUID"); + + // "even if every signal file is destroyed by an external actor": + // there are zero signal files — identity must already be complete. + assert_eq!(std::fs::read_dir(&h.signal_root).unwrap().count(), 0); + + // identity row (A13 arm 1 + signal-drain prerequisite) + let row = h + .ws_state + .identity + .get(&tid) + .expect("identity row exists at create"); + assert_eq!(row.provider.as_deref(), Some("claude")); + assert_eq!(row.session_id.as_deref(), Some(sid.as_str())); + + // registry row (GET /api/terminals rung 0) + let reg = h + .registry + .identity_probe_rows() + .into_iter() + .find(|r| r.terminal_id == tid) + .expect("registry row"); + assert_eq!(reg.resume_session_id.as_deref(), Some(sid.as_str())); + + // durable ledger binding, and it survives a "restart" (fresh PaneLedger + // over the same dir re-reads disk — the pane_ledger_restore.rs idiom). + let binding = h.ledger.load_binding("claude", &sid).expect("Bound row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid.as_str())); + let reread = freshell_ws::pane_ledger::PaneLedger::new(Some(h.ledger_dir.clone())); + assert!( + reread.load_binding("claude", &sid).is_some(), + "binding durable across restart" + ); + + h.registry.kill(&tid); + cleanup(&h); +} + +/// 4b — A13: a WS resume (terminal.create restore:true + wire sessionRef) +/// of a session that is LIVE inside a REST-created pane is REFUSED loudly. +/// This is the exact drill violation: two live claude CLIs on one session id. +#[tokio::test(flavor = "multi_thread")] +async fn ws_resume_of_session_live_in_rest_pane_is_refused_a13() { + let mut h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + + h.ws.send(WsMessage::Text( + json!({ + "type": "terminal.create", + "requestId": "req-a13-rest-live-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + "restore": true, + "sessionRef": { "provider": "claude", "sessionId": sid }, + }) + .to_string(), + )) + .await + .unwrap(); + + let err = expect_refusal_for(&mut h.ws, "req-a13-rest-live-1").await; + assert_eq!( + err["code"], + json!("RESTORE_UNAVAILABLE"), + "exact wire code: {err}" + ); + assert!( + err["message"].as_str().unwrap().contains(&sid), + "message names the live session: {err}" + ); + + // no duplicate spawn: the REST pane is still the only claude terminal + let rows = h.registry.identity_probe_rows(); + assert_eq!( + rows.len(), + 1, + "no second claude CLI on session {sid}: {rows:?}" + ); + assert_eq!(rows[0].terminal_id, tid); + + h.registry.kill(&tid); + cleanup(&h); +} + +/// 4c — the SessionStart signal for a REST pane is CONSUMED (Acted), not +/// retained forever: the confirmation no-op arm requires the identity row +/// that REST creates now write. +#[tokio::test(flavor = "multi_thread")] +async fn rest_pane_session_start_signal_is_consumed_not_retained() { + let h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + + let watcher = freshell_ws::claude_signal::ClaudeSignalWatcher::new(h.signal_root.clone()); + std::fs::write( + h.signal_root.join(format!("{tid}__1.json")), + format!(r#"{{"session_id":"{sid}","source":"startup","hook_event_name":"SessionStart"}}"#), + ) + .expect("write signal file"); + + freshell_ws::claude_signal::drain_and_rebind_claude(&h.ws_state, &watcher).await; + tokio::task::yield_now().await; + + // Acted (same-id confirmation no-op) => file deleted. Before this fix + // the pane had no identity row => Retain forever (the drill's retained + // signal in ~/.freshell/session-signals/claude/). + assert_eq!( + std::fs::read_dir(&h.signal_root).unwrap().count(), + 0, + "signal consumed, not retained" + ); + // identity unchanged by the confirmation + assert_eq!( + h.ws_state.identity.get(&tid).unwrap().session_id.as_deref(), + Some(sid.as_str()) + ); + + h.registry.kill(&tid); + cleanup(&h); +} + +/// Ledger A2 regression: REST pane EXIT retires identity. Without retire, a +/// dead REST pane stays live-looking (session directory `is_running: true`, +/// session_directory.rs:716-766) and a late SessionStart with a NEW id skips +/// the `current.retired -> Acted` arm and durably rebinds the dead pane +/// (claude_signal.rs:253-342). +#[tokio::test(flavor = "multi_thread")] +async fn dead_rest_pane_is_retired_and_late_signal_does_not_rebind_it() { + let h = spawn_merged_server().await; + let (tid, sid) = rest_create_claude(&h).await; + + h.registry.kill(&tid); + // The exit hook drives binder.retire_pane_identity asynchronously: poll + // (bounded, <=5s) until the identity row for `tid` reports retired (the + // same retired-row probe the WS kill-path tests use). + wait_until( + Duration::from_secs(5), + || h.ws_state.identity.get(&tid).is_some_and(|row| row.retired), + "identity row retired after REST pane exit", + ) + .await; + + // Late signal carrying a NEW session id for the dead pane: + let watcher = freshell_ws::claude_signal::ClaudeSignalWatcher::new(h.signal_root.clone()); + const NEW_SID: &str = "29a53649-9999-4888-8777-666655554444"; + std::fs::write( + h.signal_root.join(format!("{tid}__2.json")), + format!( + r#"{{"session_id":"{NEW_SID}","source":"startup","hook_event_name":"SessionStart"}}"# + ), + ) + .expect("write signal file"); + freshell_ws::claude_signal::drain_and_rebind_claude(&h.ws_state, &watcher).await; + + // Retired no-op arm: signal consumed; NO rebind of the dead pane, NO + // durable ledger row naming the dead terminal. + assert_eq!( + std::fs::read_dir(&h.signal_root).unwrap().count(), + 0, + "signal consumed via the retired arm, not retained" + ); + assert!( + h.ledger.load_binding("claude", NEW_SID).is_none(), + "no durable binding to a dead terminal id" + ); + // the dead pane's identity keeps its ORIGINAL session id (no rebind) + assert_eq!( + h.ws_state.identity.get(&tid).unwrap().session_id.as_deref(), + Some(sid.as_str()), + "retired pane identity untouched by the late signal" + ); + cleanup(&h); +} + +/// Resume direction (Required Outcome 2): a REST claude create WITH a +/// sessionRef now writes the identity row and a durable ledger binding +/// (previously: live registry row only — died at restart). +#[tokio::test(flavor = "multi_thread")] +async fn rest_claude_resume_create_writes_identity_row_and_ledger_binding() { + let h = spawn_merged_server().await; + // Fresh REST pane mints S, then kill it so S is no longer live-owned. + let (tid1, sid) = rest_create_claude(&h).await; + h.registry.kill(&tid1); + // wait until the row leaves Running AND the identity row is retired so + // the D7 guard's two arms both admit the resume (bounded deadline). + wait_until( + Duration::from_secs(5), + || { + let row_gone_or_exited = h.registry.identity_probe_rows().iter().all(|r| { + r.terminal_id != tid1 || r.status != freshell_protocol::TerminalRunStatus::Running + }); + let identity_retired = h + .ws_state + .identity + .get(&tid1) + .is_some_and(|row| row.retired); + row_gone_or_exited && identity_retired + }, + "REST pane fully dead (registry row not Running, identity retired)", + ) + .await; + + // REST resume of S: POST /api/tabs {"mode":"claude","sessionRef":{...}}. + let (tid2, sid2) = rest_create_claude_with_session_ref(&h, &sid).await; + assert_eq!(sid2, sid); + let row = h + .ws_state + .identity + .get(&tid2) + .expect("resume writes the identity row"); + assert_eq!(row.session_id.as_deref(), Some(sid.as_str())); + let binding = h + .ledger + .load_binding("claude", &sid) + .expect("resume writes the binding row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid2.as_str())); + + h.registry.kill(&tid2); + cleanup(&h); +} From 4d5310436730de0950cd139fea59f65fa12ff996 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:51:43 -0700 Subject: [PATCH 14/15] test(ws): pin REST codex/opencode create -> identity row + ledger binding end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../tests/rest_locator_identity.rs | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 crates/freshell-ws/tests/rest_locator_identity.rs diff --git a/crates/freshell-ws/tests/rest_locator_identity.rs b/crates/freshell-ws/tests/rest_locator_identity.rs new file mode 100644 index 000000000..e34014910 --- /dev/null +++ b/crates/freshell-ws/tests/rest_locator_identity.rs @@ -0,0 +1,478 @@ +#![cfg(unix)] +//! kata hbsa Required Outcome 3 pins: a REST-created codex/opencode pane +//! ends with BOTH the identity row and a durable Bound ledger row, end to +//! end across the freshagent/ws crate boundary. (The exploration found the +//! drain arms and the sweep pinned separately on opposite sides of that +//! boundary, but no end-to-end pin — the exact blind spot the claude gap +//! survived in.) +//! +//! Harness copied from `rest_claude_identity.rs`'s merged REST+WS server +//! (tests/ files don't share code except `common`; the codex-locator wiring +//! makes this copy non-identical, so it stays local per the extraction +//! rule), extended with the codex locator shared into BOTH `WsState` and +//! `FreshAgentState` plus the 150 ms locator sweep — mirroring +//! `freshell-server/src/main.rs`'s production wiring and +//! `common::spawn_server_with_specs_activity_and_codex_locator`. +//! +//! Isolation rules (AGENTS.md + the live 3002 server): temp-dir ledger via +//! `PaneLedger::new(Some(..))` (never `new_locked`), temp-dir signal and +//! sessions roots, `common::isolate_amplifier_home()`, no `HOME` mutation. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use freshell_ws::pane_ledger::RowState; +use freshell_ws::WsState; + +struct Harness { + base_url: String, // http://{addr} + /// Held open so the server keeps at least one WS peer alive for the + /// duration (parity with the production shape); the pins themselves + /// assert on state handles, not wire frames. + _ws: common::TestWs, + registry: freshell_terminal::TerminalRegistry, + ws_state: WsState, + ledger: Arc, + ledger_dir: std::path::PathBuf, + signal_root: std::path::PathBuf, +} + +/// Unique temp dir (pid + nanos, per pane_ledger_restore.rs:13-24). +fn unique_temp_dir(label: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "rest-locator-identity-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// The `rest_claude_identity.rs::spawn_merged_server` shape: ONE +/// `TerminalIdentityRegistry` and ONE `Arc` shared by `WsState` +/// and the `LedgerPaneIdentityBinder` handed to `FreshAgentState` +/// (main.rs's wiring), parameterized with the CLI specs this file needs and +/// an OPTIONAL codex locator root. When `codex_sessions_root` is `Some`, the +/// SAME `Arc` is wired into `WsState.codex_locator` AND +/// `FreshAgentState::with_codex_locator` (main.rs:390-411's production +/// shape: REST creates arm it, the WS-side sweep drains it), and the 150 ms +/// locator sweep is spawned exactly as +/// `common::spawn_server_with_specs_activity_and_codex_locator` does. +async fn spawn_merged_server( + cli_specs: Vec, + codex_sessions_root: Option, +) -> Harness { + let _ = common::isolate_amplifier_home(); + let ledger_dir = unique_temp_dir("ledger"); + let signal_root = unique_temp_dir("signals"); + + let auth_token = Arc::new(common::AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = Arc::new( + serde_json::from_value(common::test_settings_value()).expect("valid settings fixture"), + ); + let registry = freshell_terminal::TerminalRegistry::new(); + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + ledger_dir.clone(), + ))); + let cli_commands = Arc::new(cli_specs); + let codex_locator = codex_sessions_root + .map(|root| Arc::new(freshell_sessions::codex_locator::CodexLocator::new(root))); + + let state = WsState { + pane_ledger: Arc::clone(&ledger), + identity: identity.clone(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-test".to_string()), + boot_id: Arc::new("boot-test".to_string()), + settings, + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: registry.clone(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::clone(&cli_commands), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: codex_locator.clone(), + // The identity/ledger lane under test never touches the activity + // hub (apply_codex_identity's hub block is `if let Some`); None + // keeps the harness minimal, same as rest_claude_identity.rs. + activity: None, + session_existence: Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + + // The REST door: same auth token + broadcast bus + terminal registry as + // the WS door, with the read-side identity lookup AND the write-side + // pane-identity binder wired over the SAME identity/ledger instances, + // plus the SAME codex locator Arc (main.rs:297-310 + 390-411's shape). + let fresh_agent_state = freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ) + .with_cli_commands(Arc::clone(&cli_commands)) + .with_terminal_registry(registry.clone()) + .with_session_identity(Arc::new(identity.clone())) + .with_pane_identity_binder(Arc::new( + freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new( + identity.clone(), + Arc::clone(&ledger), + ), + )) + .with_codex_locator(codex_locator.clone()); + + if codex_locator.is_some() { + // Mirrors main.rs's sweep wiring; 150 ms re-declared because + // main.rs's LOCATOR_SWEEP_INTERVAL is private to the server binary + // (the common::spawn_server_with_specs_activity_and_codex_locator + // precedent). + freshell_ws::codex_association::spawn_codex_locator_sweep( + state.clone(), + Duration::from_millis(150), + ); + } + + let app = + freshell_ws::router(state.clone()).merge(freshell_freshagent::router(fresh_agent_state)); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let ws_url = format!("ws://{addr}/ws"); + let (ws, _inventory) = common::connect_and_capture_inventory(&ws_url).await; + + Harness { + base_url: format!("http://{addr}"), + _ws: ws, + registry, + ws_state: state, + ledger, + ledger_dir, + signal_root, + } +} + +/// A parsed minimal HTTP response. +struct RestResponse { + status: u16, + body: String, + json: serde_json::Value, +} + +/// Minimal hand-rolled HTTP/1.1 POST over a raw `TcpStream` (ported from +/// `rest_claude_identity.rs::raw_post_tabs`, path parameterized). +async fn raw_post(base_url: &str, path: &str, body_json: &serde_json::Value) -> RestResponse { + let host = base_url + .strip_prefix("http://") + .expect("base_url is http://{addr}"); + let body = body_json.to_string(); + let request = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}\r\n\ + x-auth-token: {token}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {len}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + token = common::AUTH_TOKEN, + len = body.len(), + ); + + let mut stream = TcpStream::connect(host).await.expect("connect to server"); + stream + .write_all(request.as_bytes()) + .await + .expect("write HTTP request"); + let mut raw = Vec::new(); + tokio::time::timeout(Duration::from_secs(15), stream.read_to_end(&mut raw)) + .await + .expect("HTTP response within deadline") + .expect("read HTTP response"); + let text = String::from_utf8(raw).expect("utf8 HTTP response"); + + let (head, response_body) = text + .split_once("\r\n\r\n") + .expect("HTTP header/body separator"); + let status: u16 = head + .split_whitespace() + .nth(1) + .expect("status code in status line") + .parse() + .expect("numeric status code"); + let json = serde_json::from_str(response_body.trim()).unwrap_or(serde_json::Value::Null); + RestResponse { + status, + body: response_body.to_string(), + json, + } +} + +/// Fresh REST create: POST /api/tabs {"mode":,"cwd":} (no +/// sessionRef — identity is in flight). Returns `(terminal_id, pane_id)` +/// from the response body's `data` (the REST HTTP body carries +/// {tabId, paneId, terminalId}). +async fn rest_create(h: &Harness, mode: &str) -> (String, String) { + let resp = raw_post( + &h.base_url, + "/api/tabs", + &json!({ "mode": mode, "cwd": std::env::temp_dir().to_string_lossy() }), + ) + .await; + assert_eq!(resp.status, 200, "REST create failed: {}", resp.body); + let tid = resp.json["data"]["terminalId"] + .as_str() + .unwrap_or_else(|| panic!("REST body carries data.terminalId: {}", resp.body)) + .to_string(); + let pane_id = resp.json["data"]["paneId"] + .as_str() + .unwrap_or_else(|| panic!("REST body carries data.paneId: {}", resp.body)) + .to_string(); + (tid, pane_id) +} + +/// POST /api/panes/{pane_id}/send-keys {"data":} — the REST input +/// surface whose Enter feeds the codex locator's `note_submit` +/// (terminal_tabs.rs's send_keys route; ordering pinned in-crate by +/// `send_keys_enter_feeds_codex_locator`). +async fn send_keys(h: &Harness, pane_id: &str, data: &str) { + let resp = raw_post( + &h.base_url, + &format!("/api/panes/{pane_id}/send-keys"), + &json!({ "data": data }), + ) + .await; + assert_eq!(resp.status, 200, "REST send-keys failed: {}", resp.body); +} + +/// One plugin-shaped opencode signal file (`__.json` +/// with a timestamp-first nonce — copied from `opencode_switch_rebind.rs`). +fn write_opencode_signal(root: &std::path::Path, terminal_id: &str, seq: u64, session_id: &str) { + std::fs::create_dir_all(root).unwrap(); + let name = format!("{terminal_id}__{seq:014}-000001-1.json"); + std::fs::write( + root.join(name), + format!(r#"{{"session_id":"{session_id}","source":"opencode-tui-plugin"}}"#), + ) + .unwrap(); +} + +/// The codex rollout session_meta first line (non-fork shape), exactly what +/// the real codex CLI writes — copied from `codex_fork_rebind.rs`'s +/// `session_meta_line` fixture with `forked_from: None`. +fn session_meta_line(thread_id: &str, cwd: &str) -> String { + json!({ + "timestamp": "2026-07-27T12:00:00.000Z", + "type": "session_meta", + "payload": { "id": thread_id, "session_id": thread_id, "cwd": cwd }, + }) + .to_string() +} + +/// Poll `cond` every 50ms until it holds or `deadline` elapses (panic). +async fn wait_until(deadline: Duration, mut cond: impl FnMut() -> bool, what: &str) { + let end = tokio::time::Instant::now() + deadline; + while tokio::time::Instant::now() < end { + if cond() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("timed out waiting for {what}"); +} + +fn cleanup(h: &Harness) { + std::fs::remove_dir_all(&h.ledger_dir).ok(); + std::fs::remove_dir_all(&h.signal_root).ok(); +} + +/// REQ 3 pin (signal lane): a fresh REST opencode pane, first-bound by its +/// TUI-plugin signal, ends with the identity row + a durable Bound ledger +/// binding — and the Task 5 binder's create-time pending marker is consumed +/// by the resolution (`resolve_pending`'s binding-first order). Drives +/// opencode's first-bind arbitration arm (opencode_signal.rs D1.2 arm (0a)) +/// exactly as the plugin would, via the pub drain (no sweep-timer race). +#[tokio::test(flavor = "multi_thread")] +async fn rest_created_opencode_pane_binds_identity_row_and_ledger() { + let h = spawn_merged_server(vec![common::sleeper_cli_spec("opencode")], None).await; + let (tid, _pane_id) = rest_create(&h, "opencode").await; + + // The Task 5 binder wrote the pending marker at create (the + // MARKER_MODES arm of register_create_identity): identity is still in + // flight, so there is a durable marker and NO premature identity row. + let marker = h + .ledger + .pending_for_terminal(&tid) + .expect("pending marker for locator-resolved provider at create"); + assert_eq!(marker.mode, "opencode"); + assert!( + h.ws_state.identity.get(&tid).is_none(), + "no premature identity row before the signal resolves" + ); + + // Forge the plugin signal and drive the opencode drain synchronously. + // VALID `ses_` + alphanumeric shape (is_valid_opencode_session_id + // rejects underscores/hyphens; opencode_switch_rebind.rs uses + // `ses_` + 26 alphanumerics). + const S: &str = "ses_restopencodepin00000000001"; + let watcher = freshell_ws::opencode_signal::OpencodeSignalWatcher::new(h.signal_root.clone()); + write_opencode_signal(&h.signal_root, &tid, 1, S); + freshell_ws::opencode_signal::drain_and_rebind_opencode(&h.ws_state, &watcher).await; + tokio::task::yield_now().await; + + // Identity row (the A13/signal-drain home). + let row = h.ws_state.identity.get(&tid).expect("identity row"); + assert_eq!(row.provider.as_deref(), Some("opencode")); + assert_eq!(row.session_id.as_deref(), Some(S)); + // Durable Bound ledger row naming this terminal. + let binding = h + .ledger + .load_binding("opencode", S) + .expect("Bound ledger row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid.as_str())); + assert_eq!(binding.state, RowState::Bound); + // resolve_pending consumed the create-time marker (binding-first order). + assert!( + h.ledger.pending_for_terminal(&tid).is_none(), + "resolve_pending consumed the pending marker" + ); + // Acted signal is deleted (act-then-delete, D1.1 — drain disposition + // unchanged, per the #578/#579 pins). + assert_eq!( + std::fs::read_dir(&h.signal_root).unwrap().count(), + 0, + "acted-on signal file consumed" + ); + + h.registry.kill(&tid); + cleanup(&h); +} + +/// REQ 3 pin (locator lane): a fresh REST codex pane resolved by the codex +/// locator sweep ends with the identity row + a durable Bound ledger +/// binding, with the Task 5 pending marker consumed. +/// +/// FULL-LOCATOR version (the brief's decision rule): `codex_fork_rebind.rs` +/// shows the locator-specific harness is well under 100 lines (fake-codex +/// spec + session_meta fixture + the Enter/rollout dance ≈ 60 lines beyond +/// the merged harness this file already carries) — AND the sketched +/// fallback is not implementable without production changes anyway: every +/// resolution-tail entry point is crate-private (`ledger_resolve_identity` +/// pane_ledger.rs:878, `drain_and_associate` codex_association.rs:93, +/// `adopt_codex_identity` codex_identity.rs:60); the only public codex +/// drain IS the locator sweep (`spawn_codex_locator_sweep`). +/// +/// Codex identity is Enter-anchored (codex_association.rs): the submit is +/// fed through the REST send-keys surface (`note_submit`, pinned in-crate +/// by terminal_tabs.rs's `send_keys_enter_feeds_codex_locator`), then the +/// forged rollout is correlated by the 150 ms sweep — the two-Enter +/// determinism dance copied from `codex_fork_rebind.rs` steps 2a-2e. +#[tokio::test(flavor = "multi_thread")] +async fn rest_created_codex_pane_binds_identity_row_and_ledger() { + const T: &str = "019fa7aa-1111-4222-8333-000000000073"; + + let sessions_root = unique_temp_dir("codex-sessions"); + let sessions_day = sessions_root.join("2026").join("07").join("27"); + std::fs::create_dir_all(&sessions_day).expect("sessions tree"); + + let h = spawn_merged_server( + vec![common::sleeper_cli_spec("codex")], + Some(sessions_root.clone()), + ) + .await; + + // 1. REST create {mode:"codex"} — arms the shared locator (in-crate pin: + // REST codex create must arm) and writes the pending marker. + let (tid, pane_id) = rest_create(&h, "codex").await; + let marker = h + .ledger + .pending_for_terminal(&tid) + .expect("pending marker for locator-resolved provider at create"); + assert_eq!(marker.mode, "codex"); + assert!( + h.ws_state.identity.get(&tid).is_none(), + "no premature identity row before the sweep resolves" + ); + + // 2. First REST Enter: takes the FIRST-submit re-snapshot and opens the + // 2 s adoption window — the rollout must NOT exist yet. + send_keys(&h, &pane_id, "\r").await; + // 2b. Let that first window resolve with zero candidates (2 s deadline + + // 150 ms sweep, with margin) — codex_fork_rebind.rs's determinism + // dance. + tokio::time::sleep(Duration::from_secs(3)).await; + + // 3. NOW forge the rollout the sweep correlates (payload.cwd matches the + // pane's cwd — the same temp_dir the create carried). + let cwd = std::env::temp_dir().to_string_lossy().to_string(); + let rollout = sessions_day.join(format!("rollout-2026-07-27T12-00-00-{T}.jsonl")); + std::fs::write(&rollout, format!("{}\n", session_meta_line(T, &cwd))) + .expect("write rollout fixture"); + + // 4. Second Enter re-opens the window WITHOUT re-snapshotting; the + // rollout is deterministically the sole new candidate. + send_keys(&h, &pane_id, "\r").await; + + // 5. Await the sweep (bounded poll, <=5s) until the identity row lands. + wait_until( + Duration::from_secs(5), + || h.ws_state.identity.get(&tid).is_some(), + "codex locator adoption of the REST pane", + ) + .await; + + let row = h.ws_state.identity.get(&tid).expect("identity row"); + assert_eq!(row.provider.as_deref(), Some("codex")); + assert_eq!(row.session_id.as_deref(), Some(T)); + let binding = h.ledger.load_binding("codex", T).expect("Bound ledger row"); + assert_eq!(binding.live_terminal_id.as_deref(), Some(tid.as_str())); + assert_eq!(binding.state, RowState::Bound); + assert!( + h.ledger.pending_for_terminal(&tid).is_none(), + "resolve_pending consumed the pending marker" + ); + + h.registry.kill(&tid); + std::fs::remove_dir_all(&sessions_root).ok(); + cleanup(&h); +} From ef3014bab6f3bbc467e6a672045bbf8a680227d6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:35:22 -0700 Subject: [PATCH 15/15] test: add missing auto_resume_cancels field to test initializers after merge --- crates/freshell-ws/tests/rest_claude_identity.rs | 1 + crates/freshell-ws/tests/rest_locator_identity.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index 2925190c6..9508db901 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -74,6 +74,7 @@ async fn spawn_merged_server() -> Harness { settings, broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + auto_resume_cancels: Default::default(), fresh_codex: freshell_freshagent::FreshCodexState::new( Arc::clone(&auth_token), Arc::clone(&broadcast_tx), diff --git a/crates/freshell-ws/tests/rest_locator_identity.rs b/crates/freshell-ws/tests/rest_locator_identity.rs index e34014910..c43a82001 100644 --- a/crates/freshell-ws/tests/rest_locator_identity.rs +++ b/crates/freshell-ws/tests/rest_locator_identity.rs @@ -97,6 +97,7 @@ async fn spawn_merged_server( boot_id: Arc::new("boot-test".to_string()), settings, broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_cancels: Default::default(), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, fresh_codex: freshell_freshagent::FreshCodexState::new( Arc::clone(&auth_token),