Finding B. Capping to the newest 512 evicted exactly the entries a long-lived
profile needs most: a ledger entry's only job is to stop a tab the user closed
being folded back in, and a re-hoisted id returns that tab to the plane the tab
bar renders from — on screen, not merely in state.
Now drops ids no ssh partition mentions any more. That bounds growth by what the
host actually offers rather than by an arbitrary count, and it can never evict an
entry that is still load-bearing, because an entry is only dropped once the tab
it guards is gone from the partition too. MAX_HOISTED_TAB_IDS is removed.
Mutation-proofed: restoring the recency cap reddens the new 600-tab eviction test
alone. 512 tests across the persistence suites, E2E still green.
Finding A. The resurrection guard reused sshHoistedTabIds, so a unified record
arriving on a LATER launch than its tabsByWorktree entry was suppressed forever —
tab present, permanently unrenderable, which is the exact failure this fold
exists to prevent. The two planes can arrive on different launches, so 'this tab
was hoisted' and 'this tab's unified record was hoisted' are different facts and
need different ledgers.
My first attempt at this reddened three unrelated tests and I reverted it. The
cause was a crossed pair of add() calls — the tabsByWorktree loop stamping the
unified ledger and vice versa — not the design. Worth recording because I had
guessed at an 'if (changed)' persistence problem instead, and that guess was
wrong.
No fallback seed from the old ledger: it would look like a safe migration while
reintroducing Finding A for every id already in it, and the key is branch-only
so no profile carries it.
Mutation-proofed: sharing the ledger reddens the new split-arrival test alone.
17/17 in the file, 499 with persistence.test.ts, E2E still green.
Finding B is NOT fixed and its comment now says so plainly: the new ledger shares
MAX_HOISTED_TAB_IDS, so an evicted id can still resurrect a closed tab — and now
into the plane the tab bar renders, i.e. on screen.
Regression I introduced in 22cfba5654. Closing the last tab EMPTIES
unifiedTabs[worktree] without deleting the key, so a bare id-diff re-added every
closed tab — and this is the plane the tab bar hydrates from, so they came back
ON SCREEN. The whole-record guard it replaced had blocked that only by accident,
because [] is truthy. Filters against a SNAPSHOT of the hoist ledger, since the
tabsByWorktree loop stamps ids into it before this loop runs for the same
partition.
Also fixes the fixture that had been hiding all of this. unifiedTabs is validated
by tabSchema, and salvagingArray DROPS a record missing any non-optional field —
silently, leaving an empty array rather than failing the parse. paneSession() had
no unifiedTabs at all, so every test in this file was blind to the plane that
actually renders the tab bar. It now carries a schema-complete record.
Two tests added, each mutation-proved independently: a tab the partition gains
after an earlier hoist still reaches unifiedTabs, and a closed tab does not come
back. Dropping the resurrection guard reddens the second and nothing else.
E2E re-run after the guard: still green, closing the reviewer's residual risk
that the first-upgrade path might be affected.
Review established that only the unifiedTabs fold is load-bearing: hydration
iterates unifiedTabs for membership and never reads tabsByWorktree, and
adoptGrouplessTabs mints a group, activeGroupId and layout for any tab lacking
one. So my earlier rationale for the group folds was wrong — short-circuiting
tabGroups, tabGroupLayouts and activeGroupIdByWorktree leaves the E2E green.
The real defect was granularity. The tabsByWorktree loop unions per tab; this one
replaced whole-record per worktree. Because ssh:<target> is a LIVE plane, once
local had any record every later partition-created tab was folded into
tabsByWorktree — satisfying persistPtyBinding, so a Store-level oracle stays
green — stamped as hoisted, and never reached unifiedTabs. Permanently invisible
in the tab bar, for exactly the cohort this migration serves. Now unions per tab.
KNOWN COVERAGE GAP, stated rather than papered over: no test pins this. The E2E
covers only the first upgrade, where local has no unified record. I attempted a
unit test and could not construct a partition fixture whose unifiedTabs survives
the load parse — the unified Tab shape differs from TerminalTab — so I removed it
rather than leave a red or vacuous test behind. The three dead group loops are
also left in place rather than removed blind; both belong in the follow-up.
A second live defect with the same user-visible symptom as the reported one, and
NOT covered by the partition hoist — it needs no partition at all.
A remote snapshot is terminal-only: RemoteWorkspaceSession has no unifiedTabs or
tabGroups field to carry, so replacing tabsByWorktree leaves the model
inconsistent both ways — remote tabs no group's tabOrder names, and group entries
naming a local tab the replace removed. The tab bar renders from tabOrder, so
those tabs exist and are invisible. Reachable on a fresh client, a reset profile,
or tabs created from another client.
I tried the obvious fix — calling reconcileWorktreeTabModel for each replaced key
after hydrateTabsSession — and it breaks 'converges the active-terminal repair
instead of re-running it forever'. Reopening a repair loop is worse than the bug,
so it is reverted and recorded rather than shipped. Any fix must converge; the
durable answer is likely teaching the snapshot to carry the group model, which is
a capability-negotiated wire change with its own review.
The spec was failing on a fixture defect, not a code one. It staged a PARTIAL
session into the ssh partition, and parseWorkspaceSessionsByHostId validates each
partition independently and drops it ENTIRELY when a required field is absent —
absence is deliberately fatal for foreign payloads. So the partition never
survived load, the hoist saw no partitions at all, and the spec failed for a
reason unrelated to the code under test. An old build wrote the whole session
blob via setWorkspaceSession(session, hostId), so the fixture now does too.
The helper also re-reads the file and reports what actually landed: its previous
anti-vacuity checks measured what it wrote rather than what survived load, which
is precisely how it staged nothing while asserting it had staged three tabs.
Verified both directions end to end: with the hoist, all three tabs return, every
pane is bound to a live pty, and the host shell count is unchanged. With the
load-time hoist disabled, 1 tab of 3 — the reporter's symptom exactly.
My planned union of the group loops was NOT applied: the diagnostic showed those
loops never ran and local had no group to union with, so it would have been a
change with no evidence behind it.
The tab bar renders from the active group's tabOrder, not from tabsByWorktree,
so a tab folded in without its group exists in the session and stays invisible.
The hoist now also carries tabGroups, tabGroupLayouts, unifiedTabs and
activeGroupIdByWorktree, and the staging helper moves them so the fixture is the
shape an old build actually wrote.
Necessary but NOT sufficient: the E2E still restores 1 tab of 3. Anti-vacuity
assertions confirm the staging moved tabs, layouts and a group, so the fixture is
not the problem. Leading hypothesis, unverified, is recorded in the spec: the
hoist is local-wins per worktree and launch 2 may reach it with a group already
minted for the SSH worktree, so the partition's real group is skipped.
Left as fixme with the narrowed diagnosis rather than guessed at — inverting the
local-wins arbitration is a data-loss risk in the other direction and should not
be done without evidence.
Stages the shape an upgrading user actually carries — panes ONLY in
workspaceSessionsByHostId['ssh:<target>'], no hoist stamp — then launches and
asserts the renderer's own tab bar, that every pane is bound, and that no shells
were added.
It FAILS: 1 tab restored out of 3, the reporter's exact symptom. That is the
point of committing it. The hoist folds tabsByWorktree, layouts, incarnations and
activeTabIdByWorktree, but NOT groupsByWorktree — and the tab bar renders from
the active group's tabOrder, so a hoisted tab exists in the session and stays
invisible. The Store-level oracle cannot see this: persistPtyBinding only
consults tabsByWorktree.
Marked fixme so it does not red CI, with the cause and the un-fixme condition in
the comment. The fix is incomplete, and this is the test that says so.
Reverting my own fix: review found it made one case strictly worse than not
having it.
The hoist and the retry bind sat OUTSIDE the try/catch that guards the first
bind — whose comment states exactly why it exists ('a thrown write is unknown,
not a refusal, so it must not detach anything'). Both persistPtyBinding and
structuredClone can throw, and either would escape to handlePtyReattachFailure,
turning the benign 'left running unbound, still reattachable' outcome into a
detached, attempts-exhausted pane.
It also bound against a tabId resolved BEFORE the hoist, so a pane that had
moved tabs inside the partition would be recorded under the tab it left; it
could change which tab the user is looking at mid-reconnect via
activeTabIdByWorktree; and deleting the whole retry block left 1407 SSH tests
green, so none of it was covered.
The gap it targeted is real but narrow and self-healing: a tab the CLI creates
in the partition mid-session is invisible until the next launch, when the
load-time hoist folds it in. Trading that for a worse failure mode, untested, is
not a good trade. Recorded as a known limitation instead.
All five round-8 findings, every one in tests I had just written to guard a
shipped regression — a weak test there is worse than none.
F3: the routing tests shared module-level paneKeyPtyId and each leaned on the
previous test's residue, so the mutation kill vanished under -t, it.only, or a
reorder. Each case now uses its own leaf and establishes its starting owner with
a SUCCESSFUL bind. Verified: the ownership-race test now kills the gate mutation
running alone.
F2: nothing asserted bindPaneShell's RETURNED refusalReason, which is what the
relay reads to decide whether to publish the pane at all — deleting it left 549
tests green. Now asserted in all three cases.
F1: the reset-on-entry was covered only for the expectedBinding CAS; the
expectedSourceBinding family returns earlier and was untested. Adds that case;
a reset gated on expectedBinding alone now reddens it.
F5: the unconsumed-refusal setup asserted nothing, so fixture drift that made it
SUCCEED would leave nothing to leak and pass for the wrong reason. Positive
controls added.
F4: dropped the casts — they were the only thing that could have
hidden drift between the stub and the real Store. Typed against Store instead.
The ssh:<target> plane is LIVE — the CLI and headless surfaces still write it —
but the hoist ran only in the Store constructor. A tab created there mid-session
never reached local, so a reattach refused it as noTab and its shell ran on
unbound with the tab invisible until the app restarted. Same defect class as the
original report, with a narrower window.
The reattach now re-hoists once on a noTab refusal and retries the bind before
concluding no pane owns the shell. No polling, and it costs nothing on the path
where local already knows the tab.
Mutation-proofed: making the on-demand hoist a no-op reddens the new test.
F1: nothing asserted bindPaneShell's routing gate. Reverting it to the buggy
negative form left 545 tests green — which is exactly how it shipped. New test
pins all three cases: a stale-membership refusal re-points the pane key, a
refusal with NO reason (a lost compare-and-set race) leaves it on the winner,
and a noTab refusal leaves it alone. Mutation-proofed with the exact regression.
F2: the reset-on-entry had no coverage either — every existing test uses a fresh
store, so deleting it stayed green. It is the only thing stopping one call's
reason being attributed to the next, and it becomes load-bearing the moment a
caller's store lacks the optional consume method. Mutation-proofed by deleting
the reset.
The isolation test only asserted the TAB clone; the layout is hoisted and cloned
separately, so it needed its own assertion. Mutation-proofed: aliasing the layout
reddens it.
Correction to a588f22aefd's message: it claimed cloning 'stops each hoisted tab
being serialized twice'. That is wrong — JSON.stringify expands a shared
reference at each occurrence, so the tab was already written twice and still is.
On-disk size is unchanged. The claim appears only in that commit message, not in
the code comments.
The hoist spread the partition's own tab and layout objects into the local
session, so both planes held the SAME objects. That defeats `arbitratedFrom:
'local'`, which exists precisely so a decision read from one plane cannot mutate
the other: `tab.ptyId = null` scoped to local silently cleared the partition's
binding too, and the next flush persisted it — stranding a shell whose owner
never voted on that decision. The alias became permanent when the partition
delete was replaced with a per-tab stamp.
Double-mutation itself was benign (every both-plane mutator is idempotent); the
harm is the leak of plane isolation. Cloning also stops each hoisted tab being
serialized twice.
Nothing depends on cross-plane object identity — lease comparisons key on lease
objects the hoist never touches, and tab/layout lookups go by id or leafId.
Mutation-proofed: restoring the alias reddens the new isolation test.
Finding A, a regression from the previous commit. `persistPtyBinding` returns
false from its compare-and-set paths BEFORE any reason is recorded — those are
the 'another pty owns this pane now' refusals. Treating an absent reason as
stale membership routed the pane key to the LOSER of that race, which flips
isSupersededPtyId for the winner: pty:resize and pty:signal then silently drop
for a live, visible pane, so the terminal stops resizing and Ctrl-C stops
working with no error.
Now gates on a positive 'noMembership', and the default reason is 'noTab' — the
non-publishing branch — so a future membership branch that forgets to set one
fails closed.
The reason moved off the call entirely, onto a consume-once field: existing
tests assert persistPtyBinding's call shape exactly, and an out-param in the
args object broke six of them. Also restores the two flush side effects the
rollback fix had silently dropped (F-B), and fixes the misindentation that
failed the format gate (F-C).
The mutation for finding A passed 486 tests, which is how it got in. Adds the
missing coverage: a CAS refusal must record no reason at all.
F-A. restoreSshLeaseBindings resolved its tab by looking the leaf up in a
layout, so a tab with tab.ptyId and no layout entry — the background/CLI shape
the hoist now deliberately carries — was snapshotted and then silently dropped
on restore. That left exactly the live-lease-with-no-binding state the rollback
exists to prevent, for the cohort the previous fix had just widened.
The snapshot now records which tab it came from, and the restore resolves leaf
first (so a pane that moved is restored into the tab it is in now) and falls
back to the recorded tab. A deliberate test pins the moved-leaf case; it caught
the naive version of this fix, which is why the fallback is a fallback.
F-C. Corrects the hoist-cap comment: a live tab is protected by the local-session
check, not by the cap.
The rollback paths had zero coverage, which is how F-A survived a round. Adds a
test for the snapshot; mutation-proofed by blanking the recorded tab id.
My call to leave this open was wrong, and the premise behind it was false.
`mayCreate: false` does NOT refuse only when the pane has no durable tab — it
also refuses when the tab is live and the layout is absent, or when the leaf is
missing from the layout. In those cases mobile and CLI already publish the tab,
so returning early left a visible pane wired to a stale pty while its shell ran
on unreachable. The refusal is deterministic on unchanged state, so it re-refused
every reconnect and every relaunch; leaf UUIDs are not user-addressable, so
recovery meant editing state by hand.
persistPtyBinding now reports WHY it refused. 'noTab' keeps the original guard —
publishing a pane with no tab would announce something that does not exist — and
'noMembership' registers against the live tab instead. The pane key is routed in
that case too: dropping routing on top of the binding orphaned the shell from the
surfaces that could still show it.
Mutation-proofed: reporting 'noTab' unconditionally reddens the live-tab test.
F3 (P2): the supersession undo restored lease state but not the pane bindings
the forward path cleared, so a failed flush left a predecessor holding a live
lease with no binding — and the clear queues its own save, writing that hybrid
to disk. Now snapshots via the same helpers the async twin already uses.
F5 (P3): sshHoistedTabIds grew for the life of the profile and, because the CLI
can reuse a tab id after its tab closes, a reused id was stranded forever.
Bounded to the newest 512 — far above any realistic open-tab count, and the
worst case of eviction is re-hoisting a long-closed tab once.
F4 (P3): the 'counterfactual' test no longer isolates mayCreate now that the
hoist runs before it, so it is relabelled as the end-to-end assertion it
actually is rather than left claiming a property it does not test.
F1 (P1): isPtyProvenGoneForReplacement compared the proof's id against the
APP-scoped ptyId, but the relay formats the RELAY-scoped one it was sent. The
comparison could never be true, so the previous commit's 'accept a proof naming
this shell' was behaviourally a blanket refusal — the exact stranding it was
written to fix. Now compares parsedPtyId.relayPtyId. The function had ZERO tests,
which is how an always-false branch survived two review rounds; it is exported
and covered now, including the mutation that reinstates the app-id comparison.
F2 (P1): requiring a layout to hoist a tab re-created the original tab loss for
the CLI/headless cohort. A background or CLI-created tab persists with tab.ptyId
and no layout entry until its pane first mounts, and hydration falls back to
tab.ptyId for exactly that shape. The orphan-layout direction was already handled
independently by the hasTab guard, so the tab-side clause bought nothing.
P2-C. Field-level salvage can drop a tab record while keeping its layout, or the
reverse, so the hoist could move half a pane into local: a tab with no layout
blanks its pane, and an orphan layout is unreachable. Local-plane salvage has the
same asymmetry, but hoisting moved the damage out of a quarantined partition and
into the plane everything reads. A partially-salvaged pane now stays put.
P2-D. Drops the dead !lease.updatedAt clause from the expired-lease prune —
normalizeSshRemotePtyLease always populates it, so the branch never ran.
Mutation-proofed: removing the orphan-layout guard reddens the new test.
P1-A: the one-shot stamp was wrong because ssh:<target> is a LIVE plane, not a
legacy one — orca-runtime still resolves SSH repos and folder workspaces to it.
A single flag snapshots the plane once and strands every pane added afterwards,
and unknown settings keys round-trip through older builds, so an
upgrade/downgrade/upgrade cycle stranded those tabs permanently. Now records the
tab ids it has hoisted, which keeps the hoist repeatable while still never
re-adding a tab the user closed.
P1-B: refusing every raw SSH_PTY_EXITED over-corrected. That token is all a host
too old to attest an incarnation can send, and a lease whose incarnation was
refused as synthesized produces the same. Those panes recovered before and were
left stuck. Now accepts a raw proof only when it names THIS pty — the original
over-acceptance was honouring a proof about a different shell, and an id match
is the only evidence available when there is no incarnation to check.
Both mutation-proofed: reinstating the one-shot stamp reddens the new
partition-gains-a-tab test; the id check is pinned by the reattach suites.
P1-b. supersedeSiblingLeasesForPane expired leases and cleared bindings, then
rode a bare flush() that swallows write errors — so a failed write left memory
claiming a retirement disk never recorded. Its async twin already snapshots and
rolls back; this is the same protection for the upsert path.
Returns an undo closure and uses flushOrThrow, restoring prior state and
rescheduling a save on failure. Quit is special-cased: flushOrThrow rejects once
the quit flush has started, where flush() returns early, and rolling back there
would discard a retirement for a write that was never going to happen.
The asymmetric case fell through to comparing tabId, which one side did not
have — so a client attaching with only a tabId could take a different pane's
shell in that tab. A pane key is `tabId:leafId`, so its tab half is directly
comparable; using it is strictly more checking and cannot refuse a pair that
already matched.
Mutation-proofed: ignoring the key's tab half reddens the new test alone.
Replaces the partition delete with a one-shot settings stamp. The delete broke
five deliberate tests: ssh:<target> partitions are legitimately owned by the
headless and CLI planes, and destroying them also made a rollback unable to
recover those tabs. The stamp stops the re-hoist without touching the data.
Moves the hoist BEFORE pane-identity normalization. A partition can carry a
legacy non-UUID leaf; injecting one raw skips the normalizer that exists for
exactly that, the renderer then mints a fresh UUID on hydrate, the binding
lookup misses, and that pane hits the same tab loss this change exists to fix.
The earlier claim that normalization prunes partitions was false — those
attempts failed because the test fixture was schema-invalid, not because of
ordering.
Also narrows isPtyProvenGoneForReplacement to the minted expiry (P1-a): a raw
SSH_PTY_EXITED is one the reattach could NOT tie to this shell, and honouring
it retired the owner and respawned onto a shell still in use.
Also prunes expired SSH leases older than 7 days at load (P1-d): they are
unreachable from every reattach path and previously grew for the life of the
profile. Retention sits far above the 30s pane-recovery grace window, the only
remaining reader.
A migration that leaves its source on disk runs again every launch, and its
hoist condition is 'local has no such tab' — which is exactly the state after
the user closes one. Closed panes came back on the next launch.
Deleting the partition once hoisted also completes the consolidation: local is
now the single plane for SSH pane membership, which is what the reattach binder
and the lease arbitration were already assuming.
Mutation-proofed: leaving the source behind reddens the new no-resurrection
test and nothing else.
P0-2: a paneKey is `tabId:leafId`, so comparing keys whole still compared the
tab — frozen at spawn and never updated when a pane moves. A moved pane was
refused, and an identity mismatch never grounds a respawn, so it kept a live
shell it could no longer reach. Now compares via isEquivalentPaneKey.
P0-3: restores the tab compare for the case where neither side carries a pane
key. Both identities are optional at spawn, and dropping it left such a lease
unfenced — a reset relay reissuing pty-1 could then bind a pane to a different
live shell.
The previous tests here passed 'pane-a' as a paneKey, which makePaneKey cannot
produce, so they proved nothing about production inputs. Rewritten with real
`tabId:leafUuid` keys. Both fixes are mutation-proofed: comparing whole keys
reddens the moved-pane test, and dropping the tab fallback reddens the
no-paneKey test.
P0-1. Earlier builds wrote SSH pane membership into workspaceSessionsByHostId
['ssh:<target>']. This build's binding writer is local-only and its reattach
passes mayCreate:false, so an unhoisted partition makes every pane look new,
the refusal rolls the binding back, and the upgrading user loses every tab.
Oracle is load-bearing: disabling the hoist reddens it at the same assertion.
The fixture also had to be made schema-valid — its tab was missing ptyId,
customTitle, color, sortOrder and createdAt, so tabsByWorktree salvage dropped
it at load and the test was measuring an empty partition. A real partitioned
build wrote full tab records.
Direct unit test of hoistSshPartitionsIntoLocalSession PASSES, so the migration
logic is right. Wiring it into load does not take effect: tried three points
(mid-loadState, on the final loadState result, and after
normalizePersistedPaneIdentityState with partitions captured pre-normalization).
The local session still has no hoisted pane on reopen.
The oracle now pins the intermediate step too ('the migration did not hoist the
pane into the local session'), which is what localises the remaining failure to
wiring rather than logic.
NOT SHIPPABLE. Next: instrument this.sshPartitionsAtLoad at the constructor
hoist call to find where the partitions are actually lost.
Adds hoistSshPartitionsIntoLocalSession and wires it into load. The migration
is reached (verified: it sees the ssh:<target> partition on reopen) but the
hoisted tabs are not visible on the reloaded store, so the oracle is still red.
The oracle itself is now correct: it writes the partition, flushes, and REOPENS
the profile so the load-time migration is what is under test. Its anti-vacuity
checks pass — the partition survives the reopen and local starts empty.
Do not ship. Next step is in the commit body of the follow-up.
Reapplied so the P0 fixes it carries are restored. The defects the readiness
review found in it are fixed in the commits that follow; this commit alone is
NOT shippable.
* fix(workspaces): gate the GitLab palette number match on repo identity
After a full GitLab identity compare failed, matching fell through to an
unguarded iid compare, so a pasted URL could activate a workspace from another
project or host. GitLab iids are per-project and start at 1, so small-number
collisions across projects are the norm.
Mirror the GitHub shape rather than rejecting outright: gate the number-only
fallback on a tri-state repo identity check that stays permissive when identity
is unresolvable, so forks and host aliases keep matching. A stored URL that
parses to a different project with the same type and number is a direct
contradiction and is rejected even when identity is unknown.
Refs STA-4155
* fix(workspaces): keep the GitLab repo gate permissive for fork checkouts
deriveGitRemoteIdentity keeps a single remote and ranks `upstream` above
`origin`, so a fork checkout resolves to the upstream project and the fork's
own origin is invisible to the gate. Rejecting on that mismatch dropped MR
URLs from the fork the user actually checked out — a false negative the
tri-state was meant to prevent. Treat an upstream-derived identity as unknown.
Refs STA-4155
* docs(workspaces): note that the GitLab repo identity is a one-shot snapshot
The scan cache stored the Git-admin fingerprint as an unsettled promise, so a
readdir/stat that never returns (hard NFS/SMB mount, dead sshfs, wedged cloud
FileProvider) left every later refresh awaiting it. The in-flight entry was
never cleared, so the repo fell back to persisted rows indefinitely — and since
computeResolvedWorktrees awaits all repos together, one wedged repo added 5s to
every snapshot for all of them.
Cache the settled value instead, filled by an identity-guarded writeback, and
bound the one branch that actually awaits the probe. withTimeout cannot cancel
a readdir, so an outstanding-probe guard keeps a wedged mount from issuing a
fresh probe every refresh and pinning every libuv fs thread.
Refs STA-4171
Checkpoints were inserted per synced federated dispatch and never removed; the
only eviction dropped the whole map, and none of its three call sites fire in
normal operation. A long-running federated coordinator retained one small
object per dispatch for the process lifetime.
Prune from the existing syncOrchestrationFederatedDispatch finally, which is
the one hook covering all five paths that create a checkpoint — including the
two RPCs that sync an already-terminal dispatch with no timer to prune after.
Refs STA-4014
* Center newly opened tabs at end, keep close controls visible
- Add end padding to tab strip to preserve close button visibility against fade
- Center scroll position for newly revealed end tabs, avoiding hard scroll-to-end
- Only pin-to-end for new tabs that are also active; restored mid-strip tabs stay in place
* test(tab-scroll): clarify when tabs center vs settle at end
Add lastTabGeometry helper to correctly derive scrollWidth from the pad policy. Update test expectations to document that center requests only land centered when the strip is within 2x the inset of the last tab's width; wider gaps settle at the end with inset.
* fix(tab-bar): sync active tab id ref after render
React Doctor rejects mutating refs during render. Keep the latest
active tab id in a layout effect so overflow navigation still sees
it without an impure render write.
The fingerprint gate added in #14207 reads Git's administrative layout directly -- `.git` as a file or directory, `commondir`, and per-worktree `HEAD`, `gitdir`, and `locked` -- instead of shelling out to `git worktree list`. That makes it depend on Windows path resolution, CRLF inside those files, and whether `worktree move`/`lock` and deleting a live checkout behave as they do on POSIX.
PR CI runs the vitest suite on ubuntu-latest only, so none of that was exercised. Both suites were verified by hand on a real Windows host (Git 2.55.0.windows.3, Node 24.18.0) and pass 25/25, but nothing kept them passing.
Add them to the existing curated `Test Windows-specific boundaries` step rather than standing up a new job: the `package (windows)` job already checks out and installs dependencies, so this costs only the tests themselves.
* test(terminal): pin the seven wrapper security properties that survived mutation
The PowerShell drive-relative pin was a prefix that also matched the insecure regex, so re-accepting C:foo left the suite green. Now asserts the full pattern.
Also pins all four PowerShell TrimEnd sites, the fallback wrapper-dir skip, the cmd legacy-dir trailing-separator strip, and C:/-style drive acceptance, plus a behavioural test for the POSIX legacy-dir reject with a distinct ORCA_ATTRIBUTION_SHIM_DIR. Each verified by reverting the property and confirming the suite fails.
* test(terminal): cover the trailing-separator scrub in the path matcher
isLegacyTerminalShimPathEntry sits outside the two generator modules, so the mutation campaign never re-ran it. Dropping its trailing-separator strip survived the suite: a PATH entry spelled with one would not match, leaving the legacy shim directory on the spawned PATH and the wrapper reachable. Mutation-verified on both the POSIX and Windows spellings.
* perf(worktrees): bound the duplicate-id scan in reuseEqualCatalogRows
Rows sharing an id are scanned linearly with a deep compare each, so a bucket
of k duplicates costs O(k^2). Both callers key on ids that are unique by
construction, so this is a bound on damage rather than a live fix.
Cap the scan instead of adding a second index: reuse is only an optimization,
so a missed match yields a new object identity, never a wrong row. A fingerprint
index would buy a little more reuse in a case nothing reaches, at the cost of a
second equality implementation that must stay in step with catalogValuesEqual
with no automated guard.
Worst-case duplicate bucket, no matches: k=1000 134ms -> 1.2ms, k=2000 541ms ->
2.2ms. Unique-id path unchanged (2000 rows: 0.86ms both).
* docs(worktrees): lead the duplicate-id cap comment with its reachability
A reader hitting MAX_DUPLICATE_ID_SCAN should learn first that no caller
produces duplicate ids today, so the cap reads as bounding future damage rather
than fixing something live.
* fix(terminal): clear CDPATH when resolving the wrapper directory
cd consults CDPATH for a relative operand and echoes where it landed, which the command substitution captured — wrapper_dir came out wrong, the lookup resolved to the tombstone itself, and git died at 127 with a working git on PATH.
Also pins the self-reference guard that turns that failure into a clean 127 rather than repeated self-exec; deleting it previously left the suite green. Both new tests are mutation-verified: an earlier version of each passed with the bug reintroduced because the CDPATH fixture mirrored only the first path segment.
* test(terminal): pin the wrapper guards that survived mutation
A mutation campaign found several security properties with no coverage. Most important: nothing asserted the Windows *reject* path for relative candidates, so deleting it reopened the cwd hijack while every accept-path assertion stayed green.
Also pins the separator variable value (any other character silently un-pins the trailing-separator fix), and grounds the interpreter-search test in the running process cwd — a relative PATH entry resolves against that, so the previous fixture in a tmpdir passed whether or not the guard existed. Each is mutation-verified.
The resolver could never succeed on Windows — no absolute candidate exists there and the PATH search split on the POSIX delimiter — so it always fell back to the ambient lookup it exists to avoid, on a wrapper Git Bash and WSL panes do execute.
Also rejects interpreter candidates containing whitespace (a shebang cannot quote) or that are directories (X_OK alone is true for those), and replaces the sentinel trailing-separator strip, which corrupted paths containing the sentinel, with a comparison against a variable holding the separator.
* Reapply #13326 and #13928 (un-revert #14361)
Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them
reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230,
STA-4232, STA-4233 and STA-4234 against #14361.
The tab loss that motivated the revert is fixed in the commits that follow, so
this reapplication is not a straight redo.
* fix(relay): stop the fallback attach fence refusing a pane that moved tabs
The primary fence was moved to the shell's own incarnation precisely because
paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had
merely moved. The fallback that older clients fall into kept the old rule, so
the correction never reached it — the same 'the rule exists, but this path does
not ask it' leak this work has hit repeatedly.
A refusal here is not recoverable: an identity mismatch never grounds a respawn,
so the pane keeps a live shell it can no longer reach and renders blank.
Narrowed to paneKey, which is the identity; the tab is a location. Restoring the
tabId comparison reddens the new test.
* fix(terminal): bake the wrapper interpreter and emit CRLF for cmd
Round-3 review: the shebang resolved bash through the inherited PATH before any script hygiene, so a relative or empty PATH element let an untrusted checkout supply the interpreter. Bakes an absolute verified interpreter with an env fallback.
Normalizes trailing separators in the cmd comparisons; without it a wrapper-dir entry spelled with a trailing backslash escaped self-exclusion and the wrapper tail-chained to itself forever.
Found while verifying on Windows: cmd resolves call targets by byte offset and fails on LF-only files once they grow (worked at 2.4 KB, failed at 3.7 KB), so the Windows wrappers now emit CRLF. Also avoids two cmd parser traps: a trailing backslash before a closing quote in an if comparison, and percent expansion inside rem comments.
* fix(terminal): resolve the wrapper interpreter from absolute PATH entries too (STA-4226)
The well-known-candidate list would fall back to an ambient env lookup on distributions that place bash elsewhere (NixOS, Guix), restoring the exposure. Search absolute PATH entries before giving up, skipping relative and empty ones because those mean the current directory.
* feat(dashboard): filter the agent map by multiple hosts
The map's host control was a single-choice segmented row in its own header:
pick All, Local, SSH, WSL, or Remote — never two at once. It also sat outside
the shared filter menu, so the menu's badge never counted it and "Clear all
filters" could not reach it.
Hosts are now checkboxes in the filter menu alongside agent state, so a fleet
split across a Mac and a Windows runtime can show both and hide the rest.
Hosts that contribute no agents are omitted, and the section hides entirely
for a single-host fleet.
Lifting `hostFilter` out of AgentMap also drops `compact`, which existed only
to hide the segmented control next to an open terminal panel.
The filter-option builders move to agent-dashboard-filter-options.ts to keep
AgentDashboardToolbar under the 400-line cap; that move is mechanical.
* feat(dashboard): give the agent map a real filter panel
The map's filters were a single-choice host row in its own header plus a few
rows borrowed from the board's dropdown. This replaces them with one panel that
owns every map facet.
- Quick views: Everything / Needs me / Stuck / Unread / Last 30 min /
Long runners / Stale > 3d / Orchestration. Each replaces the filters
wholesale rather than stacking on whatever was set.
- Provider (agent type) checkboxes.
- Three time ranges — session lifespan, since last message, time in current
state — on a non-linear scale so minutes and days are both reachable.
- Orchestration flows select the coordinator *and* the agents it dispatched;
a children-only filter hid the half that explains the flow.
- Sections collapse, each showing its value when closed. A section that is
filtering forces itself open so a collapsed row can never be the reason the
map looks emptier than the filters claim.
- The shown-count moves into the panel header, next to the controls that
change it, and now reflects the map's own facets rather than the board's.
The map's surface becomes a Popover, not a DropdownMenu: the panel holds range
sliders and a Radix menu swallows the arrow keys those need. The board keeps
its dropdown untouched via a new `filterControl` slot on the shared toolbar.
`ui/slider` renders one thumb per value so a two-value range works; the two
existing single-value callers are unchanged.
* fix(dashboard): preserve agent map filter semantics
* refine agent map filter facets
* perf(dashboard): keep map filters out of main view
* fix(macos): recover severed terminal TCC attribution after updates
When a packaged update leaves the daemon healthy but TCC-severed (spawning
binary gone), surface a Manage Sessions toast and replace the daemon before a
new terminal only when zero live sessions remain. Does not broaden FDA or
auto-kill sessions. Addresses the Orca-specific path of #13594.
* fix(macos): coalesce severed-TCC toast checks and sync i18n keys
Add catalog entries for the Manage Sessions toast strings and guard overlapping
mount/focus probes with an in-flight latch so only one infinite toast can fire.
* test(macos): harden severed TCC recovery coverage
* fix(macos): clear recovered TCC warning
* fix(macos): clarify severed TCC warning copy
* fix(macos): bound TCC attribution health checks
* fix(macos): preserve bounded attribution checks after merge
* test(macos): use real execFile callback contract
* fix(macos): cover legacy daemon attribution
* fix(macos): clarify affected Orca terminals
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>