Skip to content

feat: terminal rebind hardening — metadata re-keying, stale-bounce suppression, plugin heartbeat, rebind e2e - #573

Merged
danshapiro merged 10 commits into
mainfrom
feat/rebind-salvage-hardening
Jul 29, 2026
Merged

feat: terminal rebind hardening — metadata re-keying, stale-bounce suppression, plugin heartbeat, rebind e2e#573
danshapiro merged 10 commits into
mainfrom
feat/rebind-salvage-hardening

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Hardens the recently-merged terminal session rebind lanes (#567/#568) with four vetted salvage ports from a discarded parallel implementation plus one new improvement:

(1) Client metadata re-keying on rebind: Client fold now re-keys tab.sessionMetadataByKey old→new on terminal rebind, fixing stranded tab metadata across all three providers.

(2) In-flight terminal.input verification + stale-bounce suppression: Server now verifies in-flight terminal.input against the session the client believes it targets. Client suppresses stale SESSION_IDENTITY_MISMATCH bounces that are mere echoes of an already-folded rebind (opencode-scoped; a keystroke inside the sub-second swap window may still show one truthful "[Resume blocked]" notice — documented trade-off).

(3) Duplicate-rebind idempotence: Client tests verify no double persistence flush on rebind.

(4) End-to-end browser testing: New rust-only browser e2e proving the full opencode rebind stack end to end — signal → broadcast sequence → client fold → localStorage persistence → restart respawns --session → never-steal refusal with liveness control.

(5) Plugin heartbeat and observable degradation: Opencode rebind plugin now emits a plugin-alive hello at TUI startup. Server WARNs once (rate-limited) if an opencode pane never says hello — makes silent rebind degradation (plugin API drift after opencode self-update) observable.

Testing: All gates green:

  • Typecheck: ✓
  • Lint: ✓
  • 9,505 npm tests: ✓
  • Browser e2e: ✓

Reviews: Both independent cross-model reviews (plan and delta) passed first-round with zero blockers.

Co-Authored-By: Amplifier [email protected]

danshapiro and others added 10 commits July 29, 2026 05:09
Load-bearing assumption pass (6 verified, 4 falsified) drove:
- new Task 0: mandatory merge of origin/main @ e1f4d4c (#571) before
  implementation; anchors refreshed to the post-merge tree
- Task 4 identity guard scoped to opencode (claude/codex have identical
  swap windows; all-provider guard would bounce legitimate input)
- fail-open coverage claim rewritten (REST/fresh-agent panes are
  permanently unresolved, not a ~2s spawn window)
- Task 5 wiring test re-keyed on the '[Resume blocked]' xterm write
  (expected!=current takes the notice-only branch; old RED was invalid)
- documented accepted residual: pre-fold bounces are visibly refused
  (no cross-channel ordering guarantee exists)
- Task 8 never-steal negative gains a WsCapture liveness sentinel
  (bus lag closes captures with 4008, making negatives vacuous)
…fold

- Extend createState test fixture to accept tabOverrides for metadata setup
- Add updateTab to imports for asserting on tab update actions
- Wire caller in reconcileTerminalSessionAssociation to pass tab.sessionMetadataByKey
  to buildTerminalDurableSessionRefUpdate
- Verify metadata is re-keyed (ses_old → ses_new) on server-authoritative rebind
- Test verifies: result='reconciled', updateTab dispatch, re-keyed metadata,
  and flushPersistedLayoutNow dispatch

Generated with Amplifier
Co-Authored-By: Amplifier <[email protected]>
…association fold

Add two characterization guards for the duplicate rebind broadcast path:
1. Stale repeat whose previousSessionId no longer matches the pane current ref
2. Identical repeat that neither re-dispatches updateTab nor flushes layout again

Both tests verify idempotent behavior on repeat broadcasts when the pane
and tab have already been updated to the new session reference.

Generated with Amplifier (https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <[email protected]>
…MISMATCH carrying actualSessionRef

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <[email protected]>
…bounces

When the server bounces an in-flight input frame carrying the OLD
sessionRef (Task 4's guard) but the pane has already folded the new ref,
the echoed actualSessionRef equals the pane's current ref -- the race is
already self-healed and the '[Resume blocked]' notice is spurious. Add
an exported pure helper isStaleSessionIdentityMismatch (suppress iff
BOTH refs sanitize to full refs AND are equal; absent/partial refs fail
toward the visible path) and an early return at the top of TerminalView's
SESSION_IDENTITY_MISMATCH handler. Real mismatches still notice/repair.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <[email protected]>
…hen a pane never says hello

Teach the opencode signal drain to discriminate {"hello":true,...}
plugin-alive heartbeats (delete-on-read) from rebind signals
(act-then-delete, unchanged), returning both via the new
OpencodeDrainOutcome. A HelloTracker behind Arc<Mutex<..>> inside the
watcher keeps drain_and_rebind_opencode's public signature unchanged,
and a once-per-terminal opencode_rebind_heartbeat_missing WARN fires
for RUNNING opencode panes older than OPENCODE_HELLO_GRACE_MS (120s)
that never said hello, suppressed when the server's own env shows
injection was deliberately skipped.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <[email protected]>
- Add resolveSignalTarget() helper to extract home/terminalId resolution
- Implement emitHello() that writes a one-time hello signal at TUI startup
- Refactor createEmitter() to use the shared resolveSignalTarget()
- Hello signal: filename <terminalId>__<nonce>.json (14-digit ts, 6-digit seq, pid)
- Body: {"hello":true,"source":"opencode-tui-plugin"}
- Never throws into TUI (writer exceptions swallowed)
- Add unit tests for emitHello() with DI pattern
- Add integration test with real temp home and hostile API surface

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <[email protected]>
First browser-level proof that the full signal-driven rebind stack
composes: a Playwright spec drives a real browser against a real Rust
server, writing atomic signal files into the swept opencode signal dir.
Legs: rebind broadcast with exact [sesA, sesB] sequence, client Redux
fold, reload persistence (freshell.layout.v3), post-restart respawn argv
(--session sesB, never sesA), never-steal refusal of a pane-2-owned id,
and a same-capture positive liveness control (LB9) that keeps the
negative window non-vacuous.

Deviation from the task brief (spec-realism fix, no production change):
signal-minted ids are seeded as session rows into the fake's
opencode.db first. The reconcile existence probe correctly adjudicates
a diskless id dead_session/session_not_on_disk on the post-restart
respawn path; in production the TUI plugin only signals ids opencode
has already persisted, so the seed models the real drifted-session
shape.

Registered rust-only (RUST_ONLY_SPECS + rust-chromium testMatch): the
signal-file rebind lane exists only on the Rust server
(opencode_signal.rs).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <[email protected]>
@danshapiro
danshapiro merged commit 0cefeea into main Jul 29, 2026
3 checks passed
@danshapiro
danshapiro deleted the feat/rebind-salvage-hardening branch July 29, 2026 12:12
pull Bot pushed a commit to HinchK/freshell that referenced this pull request Jul 29, 2026
…danshapiro#574 codex-lane-self-healing) into rebind-review-polish

Semantic merge of the opencode signal lane, preserving both sides:

- main's OpencodeDrainOutcome split (rebinds + hellos), plugin-alive hello
  delete-on-read, HelloTracker with once-per-pane grace-window warning
- branch's SignalDisposition ladder (Acted/Retain/Discard) with explicit
  foreign-provider signal discard, stale .tmp staging reap, and all other
  polish items (deterministic claude drain ordering, portable
  timestamp-first nonce, sidecar guard parity, warn-once fork-ambiguity,
  percent-encoded plugin URL, codex_locator test split)

Conflicts (crates/freshell-ws/src/opencode_signal.rs):
- drain() doc + signature: kept OpencodeDrainOutcome return with combined
  doc covering hellos AND stale-.tmp reaping
- drain_and_rebind_opencode(): kept main's hello-tracker pass, folded the
  rebind loop onto the branch's SignalDisposition match
- adapted branch test drain_reaps_stale_tmp_staging_files_but_keeps_fresh_ones
  to the OpencodeDrainOutcome shape

Hello files are discriminated at parse time (before the rebind ladder), so
the foreign-signal Discard path can never consume a hello as "foreign".

Verified: cargo fmt --check, clippy -D warnings, cargo test --workspace
(2198 passed / 0 failed), contract freeze (test:port + regen idempotency +
freshell-protocol --locked), coordinated npm run check.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant