Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 41 additions & 17 deletions scripts/deploy-tab-diff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ case "$CMD" in
($snap.records // [])[] | select(.status == "open") as $rec
| ($rec.panes // [])[]
| {device: $dev, tabKey: $rec.tabKey, tabName: $rec.tabName, paneId: .paneId,
kind: .kind, sessionRef: .payload.sessionRef,
kind: .kind, mode: .payload.mode, sessionRef: .payload.sessionRef,
liveTerminalId: .payload.liveTerminal.terminalId};
($b[0].terminals | map(select(.status=="running") | .terminalId)) as $liveBefore
| ($a[0].terminals | map(select(.status=="running") | .terminalId)) as $liveNow
Expand All @@ -237,6 +237,17 @@ case "$CMD" in
| ($after | map(select(.tabKey == $bp.tabKey and .paneId == $bp.paneId)) | first) as $ap
| if $ap == null then
{verdict: "MISSING", pane: $bp}
# A LIVE coding-CLI pane with NO captured sessionRef is
# unverifiable BY CONSTRUCTION: restore can only ever produce a
# blank session for it, and "same identity" cannot be checked
# against an identity that was never captured. Silently passing
# these panes is how the 2026-07-25 blank-restore incident hid
# behind an "OK" verdict. Shell panes are stateless by design and
# are exempt; the session-capable mode list mirrors
# crates/freshell-ws/src/existence.rs.
elif ($bp.sessionRef == null
and ((["claude", "codex", "opencode", "amplifier"] | index($bp.mode)) != null)) then
{verdict: "NO CAPTURED IDENTITY", pane: $bp}
elif ($bp.sessionRef != null and $ap.sessionRef == null) then
{verdict: "FRESH (identity lost)", pane: $bp}
elif ($bp.sessionRef != null and $ap.sessionRef != null
Expand Down Expand Up @@ -264,22 +275,35 @@ case "$CMD" in
# every still-healthy pane). Everything is read from the BEFORE file +
# $DIFF, so verify performs ZERO network operations in --after/offline
# mode (:2619).
echo "REMEDIATION (rebuild each diverged device's MISSING panes from its captured immutable bundle):"
while IFS= read -r -d '' dev; do
comps=$(jq -r --arg d "$dev" '(.bundles[$d].components // []) | join(",")' "$BEFORE")
if [[ -z "$comps" ]]; then
printf 'ERROR: no captured bundle for device %q in the before-file; cannot recommend a union-consistent restore.\n' "$dev" >&2
continue
fi
pane_args=""
while IFS= read -r -d '' pk; do
pane_args+=$(printf ' --pane %q' "$pk")
done < <(jq -j --arg d "$dev" \
'[.[] | select(.pane.device == $d) | "\(.pane.tabKey)#\(.pane.paneId)"] | unique | .[] | . + "\u0000"' \
<<<"$DIFF")
printf ' scripts/restore-tabs.sh --url %q --token <TOKEN> --device %q --components %s --force%s\n' \
"$URL" "$dev" "$comps" "$pane_args"
done < <(jq -j '[.[].pane.device] | unique | .[] | . + "\u0000"' <<<"$DIFF")
# NO CAPTURED IDENTITY panes are NOT snapshot-remediable: no generation
# carries a sessionRef for them, so restore-tabs.sh could only rebuild a
# blank session (a duplicate of the loss). They get a manual-recovery
# note instead of a --pane entry.
UNVERIFIABLE=$(jq '[.[] | select(.verdict == "NO CAPTURED IDENTITY")]' <<<"$DIFF")
REMEDIABLE=$(jq '[.[] | select(.verdict != "NO CAPTURED IDENTITY")]' <<<"$DIFF")
if [[ "$(jq 'length' <<<"$UNVERIFIABLE")" != "0" ]]; then
echo "UNRECOVERABLE FROM SNAPSHOTS: identity was never captured for these live CLI panes, so restore-tabs.sh cannot rebuild them."
echo "Recover manually from the provider's own session store (e.g. 'amplifier resume', 'codex resume', 'claude --resume'), matching by cwd and last activity."
jq -r '.[] | " NO-IDENTITY device=\(.pane.device)\ttab=\(.pane.tabName) (\(.pane.tabKey))\tpane=\(.pane.paneId)\tmode=\(.pane.mode // "-")"' <<<"$UNVERIFIABLE"
fi
if [[ "$(jq 'length' <<<"$REMEDIABLE")" != "0" ]]; then
echo "REMEDIATION (rebuild each diverged device's MISSING panes from its captured immutable bundle):"
while IFS= read -r -d '' dev; do
comps=$(jq -r --arg d "$dev" '(.bundles[$d].components // []) | join(",")' "$BEFORE")
if [[ -z "$comps" ]]; then
printf 'ERROR: no captured bundle for device %q in the before-file; cannot recommend a union-consistent restore.\n' "$dev" >&2
continue
fi
pane_args=""
while IFS= read -r -d '' pk; do
pane_args+=$(printf ' --pane %q' "$pk")
done < <(jq -j --arg d "$dev" \
'[.[] | select(.pane.device == $d) | "\(.pane.tabKey)#\(.pane.paneId)"] | unique | .[] | . + "\u0000"' \
<<<"$REMEDIABLE")
printf ' scripts/restore-tabs.sh --url %q --token <TOKEN> --device %q --components %s --force%s\n' \
"$URL" "$dev" "$comps" "$pane_args"
done < <(jq -j '[.[].pane.device] | unique | .[] | . + "\u0000"' <<<"$REMEDIABLE")
fi
cleanup; exit 1
;;
*)
Expand Down
25 changes: 22 additions & 3 deletions test/e2e-browser/specs/deploy-tab-diff-rust.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ test.describe('deploy tab-diff ritual (rust only, ephemeral server)', () => {
})

// (2) DETERMINISTIC OFFLINE diff-engine coverage: drive `verify --before F
// --after F` over synthetic fixtures so ALL FOUR verdicts, the full-set-difference
// --after F` over synthetic fixtures so ALL FIVE verdicts, the full-set-difference
// coverage guard, and the multi-client bundle remediation are exercised (the live
// path can only produce MISSING). With --after supplied verify does ZERO network
// ops (:2619) -- proven by prepending a fake `curl` that ABORTS if invoked.
Expand Down Expand Up @@ -316,13 +316,20 @@ test.describe('deploy tab-diff ritual (rust only, ephemeral server)', () => {
rec('dev-1:codexRepoint', [pane('terminal', { mode: 'codex', sessionRef: { provider: 'codex', sessionId: 'S-old' } })]),
rec('dev-1:codexFresh', [pane('terminal', { mode: 'codex', sessionRef: { provider: 'codex', sessionId: 'S-fresh' } })]),
rec('dev-1:sh', [pane('terminal', { mode: 'shell', liveTerminal: { terminalId: 'T-live' } })]),
], [term('T-live', 'running'), term('T-exited', 'exited')],
// Live coding-CLI pane whose session identity was NEVER captured (no
// sessionRef in any snapshot -- the amplifier-locator gap). Restore can
// only ever produce a blank session for it, so verify must FAIL LOUDLY
// even though the pane respawns a running terminal.
rec('dev-1:cliNoId', [pane('terminal', { mode: 'amplifier', liveTerminal: { terminalId: 'T-cli' } })]),
], [term('T-live', 'running'), term('T-exited', 'exited'), term('T-cli', 'running')],
{ 'dev-1': { components: ['aaaa1111', 'bbbb2222'], capturedAt: 1000 } })) // TWO-client bundle
const after = await write('after.json', doc(2000, [
rec('dev-1:codexRepoint', [pane('terminal', { mode: 'codex', sessionRef: { provider: 'codex', sessionId: 'S-new' } })]),
rec('dev-1:codexFresh', [pane('terminal', { mode: 'codex' })]),
rec('dev-1:sh', [pane('terminal', { mode: 'shell', liveTerminal: { terminalId: 'T-gone' } })]),
], []))
// ...respawned and running, yet unverifiable: still no identity.
rec('dev-1:cliNoId', [pane('terminal', { mode: 'amplifier', liveTerminal: { terminalId: 'T-cli2' } })]),
], [term('T-cli2', 'running')]))
const d = await runOffline(['verify', '--url', 'http://unused.invalid', '--token', 't', '--before', before, '--after', after])
expect(d.code).not.toBe(0)
expect(d.code).not.toBe(99) // curl was NEVER called (:2619)
Expand All @@ -331,6 +338,18 @@ test.describe('deploy tab-diff ritual (rust only, ephemeral server)', () => {
expect(d.out).toContain('RE-POINTED')
expect(d.out).toContain('FRESH (identity lost)')
expect(d.out).toContain('NOT RESPAWNED')
// The fifth verdict: a live coding-CLI pane whose identity was never
// captured is UNVERIFIABLE -- verify must name it, not silently pass it
// (the 2026-07-25 blank-restore incident). Exactly ONE pane qualifies:
// the shell pane is stateless by design and must NOT be flagged.
expect(d.out).toContain('NO CAPTURED IDENTITY')
expect(d.out).toContain('dev-1:cliNoId')
expect((d.out.match(/NO CAPTURED IDENTITY/g) ?? []).length).toBe(1)
// Snapshots carry no identity for it, so restore-tabs.sh CANNOT rebuild
// it -- it must be excluded from the --pane remediation and called out
// for manual provider-store recovery instead.
expect(d.out).not.toContain('--pane dev-1:cliNoId#p-terminal')
expect(d.out).toMatch(/identity was never captured/i)
// Remediation uses the immutable MULTI-CLIENT bundle (BOTH component ids), not
// a single-client --generation-id (:2621).
expect(d.out).toMatch(/--components aaaa1111,bbbb2222/)
Expand Down
Loading