* fix(runtime): treat selector_not_found as definitive tab absence (#21189)
When closing a tab whose worktree selector is absent, propagate the
error through host RPC and classify it as unknown-tab on the renderer
to engage durable tombstones and prevent resurrection loops.
Pin host RPC error propagation with dedicated regression tests.
Co-authored-by: Neil Parker <neil@stably.ai>
* test(runtime): remove invalid absent-tab Docker spec
The spec dynamically imported renderer source from the browser and did not exercise a real close RPC. Keep the executable renderer and host regression coverage instead.\n\nCo-authored-by: Lesley Murfin <lesley@revivebusiness.ca>
* fix(runtime): narrow durable tab absence to tab and terminal absence (#21189)
Narrow durable close tombstones in web-runtime-session-tab-lifecycle to
tab_not_found and terminal_tab_not_found. In production, session tab close
requests pass explicit `id:` worktree selectors and take the fast path in
closeMobileSessionTab, bypassing resolveWorktreeSelector. Transient
selector_not_found errors retain normal TTL eviction.
---------
Co-authored-by: Neil Parker <neil@stably.ai>
* fix(chat): release provider children after lost resume holds
* test: load audit fixtures as modules and verify combined mobile payload
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
* fix(filesystem): match allowed roots across Unicode forms
macOS returns a path in whichever Unicode form its source held: APFS gives
back what it stores (NFD), while the file picker and git
(core.precomposeunicode) give back NFC. A workspace registered in one form
never matched a file read in the other, so fs:readFile denied a path inside
the open workspace (#21172).
isDescendantOrEqual now compares byte-exactly first and retries in NFC only
when that fails and both sides carry non-ASCII, leaving ASCII containment and
the traversal guards untouched.
* fix(filesystem): prove identity before admitting a Unicode-folded root
Canonical equivalence is not identity: APFS folds both spellings onto one
directory, but a byte-exact filesystem can hold them as distinct siblings,
and admitting the unregistered one widened the allow-list.
The NFC fold now only locates the ancestor of the target that the
registered root would have to be; containment is granted only when that
ancestor and the root stat to the same dev+ino. A failed stat or an ino of
0 denies. ASCII paths and roots that do not fold onto the target never
reach the disk.
* fix(relay): probe a half-open control socket instead of waiting out the silence bound (STA-7672)
A socket CLOSE rejects a pending request as relay_control_closed_<code>, so a
relay_control_request_timeout is positive proof the socket stayed open and
simply never replied. The only thing that reaps such a socket is
RELAY_CONTROL_SILENCE_LIMIT_MS = 75_000, combed every 15s, against a 10s
request deadline. On Windows behind NAT/VPN or across sleep-resume a half-open
TCP socket accepts send() into a dead pipe and stays invisible for 75-90s, so
every pairing attempt in that window times out. The reporter burned ~7.
A request that times out with no inbound frame since its send now arms an RFC
6455 ping probe. Terminating on the timeout alone was rejected: relay control
ops run DB transactions that can outlive the deadline, and the existing comment
in handleMessage records that self-closing on a late reply was strictly worse
than ignoring it -- it orphaned the relay session and answered the phone with
HOST_OFFLINE for minutes. The probe distinguishes the two cases instead of
guessing.
The probe deadline deliberately exceeds the relay's own 15s application-level
ping cadence. Relay liveness never depended on RFC 6455 control frames
surviving end to end, so a shorter window would let a middlebox that swallows
pongs turn every request timeout into a reconnect loop. At 20s a healthy cell
clears the probe either way -- with a pong, or with the ping it was going to
send anyway -- so a probe that fires means the pipe carried neither. Detection
drops from 75-90s to ~30s.
A pong clears a probe but deliberately does not feed the silence watchdog: it
proves the pipe, not that the relay still indexes the session.
The timeout error also stops being a bare string; it now names the request
kind, the cell, the socket age, the time since the last inbound frame, and
whether a probe was armed.
The silence watchdog, the probe and the socket age now live in one
RelayControlLiveness owner rather than scattered across RelayControlClient.
* fix(relay): require a run of unanswered probes before tearing down a control
A single unanswered probe was treated as proof of a dead pipe. STA-3320 already
established that it is not: a cellular/VPN blackhole or a stalled TCP
retransmit routinely swallows one pong from a peer that is still there, which
is why RemoteRuntimeServerHeartbeat requires three consecutive misses. The
networks this detection exists for are exactly the ones that drop a lone frame,
so the first cut was more trigger-happy than the rest of the product.
Three changes, all aimed at the cost of a false positive rather than the
detection itself:
- Three consecutive unanswered probes are now required. The interval drops to
8s so the full run (24s) still outlasts the relay's 15s application-level
ping, preserving the property that a healthy cell clears the probe even where
a middlebox swallows RFC 6455 control frames. Detection lands at ~34s rather
than ~30s, against 75-90s before the fix. Any inbound frame retires the whole
run, so a later probe never inherits an earlier miss.
- The deadline carries the fleet's existing +/-10% jitter
(RELAY_RENEWAL_JITTER_RATIO). Without it every host timing out against one
slow cell would probe and terminate on the same boundary -- the synchronized
cohort burst that constant was introduced for. The pre-existing 75s watchdog
comb has the same defect; this path does not add to it.
- A liveness teardown now names its cause in the log. It reaches the origin as
an ordinary 1006 close, so without a label a probe-driven reconnect is
indistinguishable from any other drop, and a fleet-wide false positive would
be invisible in exactly the incident where it matters.
Mutation-checked: a miss limit of 1 fails four tests, 2 fails one, and removing
the jitter fails one.
* fix(relay): keep the request-timeout rejection classifiable
The diagnostics added in the previous commit were appended to the rejection's
message, which silently destroyed the signal they were meant to add.
`mobileRelayMintFailureFromUnknown` classifies a relay failure by testing
`error.message` against an anchored `/^relay_[a-z0-9_]{1,74}$/`, so
`relay_control_request_timeout reqKind=invite cell=...` stopped matching and
every pairing timeout was reported as the generic `relay_mint_failed` instead --
in exactly the flow STA-7672 is about. The pairing path logs only the resolved
code and discards the rejection's text, so nothing ever surfaced the suffix:
the change was a net loss of diagnosis.
The message is bare again and the diagnostics are logged from
RelayControlLiveness, which is the only place they survive.
Added relay-control-timeout-classification.test.ts to pin the contract end to
end through the real classifier, since the coupling is invisible at both sites:
restoring the suffix turns the assertion into relay_mint_failed.
Found in adversarial review.
* refactor(relay): collapse the half-open detection onto one object
Design review of the three commits on this branch. No behaviour change: the
184 relay tests pass unmodified, and reverting PROBE_MISS_LIMIT to 1 or 2, or
dropping the jitter, still fails them.
Dead plumbing. `probeIntervalMs` had zero callers across three layers
(client options -> conditional spread -> liveness default), and `silenceLimitMs`
the same -- the only production construction site, relay-control-origin.ts,
passes neither. Both are gone. `livenessRandom` stays; one test uses it. The
conditional-spread idiom went with them: `exactOptionalPropertyTypes` is off for
src/ (only cloud/apps/relay-ops sets it), so it bought nothing that
`?? Math.random` does not already do.
Teardown owns its own log. A two-member reason union crossed a module boundary
just to reach a console.warn, and the client re-derived `cell=` from
relayOrigin when liveness already held `cellUrl`. Liveness now tears itself
down and calls `terminate`; the client lost the import, the method, and the
exported type.
One probe object, one interval. `probeTimer` + `missedProbes` are now
`probe: { timer, misses } | null`, so "no timer implies no misses" is structural
instead of maintained by resetting in two places, and the
sendProbe/onProbeUnanswered mutual recursion is a plain setInterval. Jitter is
computed once per run rather than per tick -- one offset already desynchronizes
the cohort.
Honest probe label. If ping() throws, the old arm path returned false and the
caller logged `probe=in-flight/0` moments after terminating the socket -- a
false statement in the line that exists for incident forensics. The arm path
now returns the label it means, including `probe=send-failed`.
Absorbed RelayControlSilenceWatchdog. It had one consumer and no test file, and
this branch had to punch a `lastInboundTime` getter through it purely so
liveness could read state it holds. `lastInboundAt` now sits next to `openedAt`;
the file, the getter, the import, and the onDead('silence-limit') lambda are all
gone.
Also: dropped `RelayControlRequestTimeout.reqId` and `PendingRequest.sentAt`
(both written, never read -- the timeout closure captures the local `sentAt`);
dropped the two `'n/a'` branches, unreachable because a request timeout can only
fire after sendActive succeeded, which requires a state only handleProofMessage
reaches on the line before it calls liveness.start(); moved the classifier
invariant off a void-returning callback type and onto REQUEST_TIMEOUT_CODE,
where an edit to the string is next to the warning about editing the string;
and replaced the `live` parameter with an `isLive()` option so liveness asks
rather than being told, which also let `liveness` be constructed before
`requests` instead of a closure reading a field assigned on a later line.
* feat(mobile-web): add the Phase A bootstrap web source
A peer of src/ so the root workspace owns it and mobile's separate lockfile
stays out of packaging. Four assets across four content types, enough to
exercise multi-asset manifest handling rather than assume it.
The page reads buildId from manifest.json at runtime: buildId hashes the asset
list that index.html belongs to, so injecting it into a hashed asset would make
that asset's hash depend on itself.
Registered as a fourth typecheck project; without it the entry would be the
only TypeScript in a release path that tsc never sees.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(build): build and verify the mobile web bundle from the root workspace
Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as
assets/<sha256>.<ext> with index.html the only stable name. buildId is the
sha256 of the canonical serialization of the sorted asset list, so it is a pure
function of content and usable as a cache key with no further reasoning.
The verifier builds twice into scratch dirs and compares: a timestamp, an
absolute path, or an unstable ordering fails the build when someone introduces
it, not the first time a phone gets a spurious cache miss. It also enforces the
Phase A budget of 16 assets and 256 KiB, separate from the permanent contract
ceiling.
build:release does not call build:desktop, so build:mobile-web is wired into
build:desktop, build:release, and build:release:parallel.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(packaging): fail the release when the mobile web bundle is missing or stale
electron-builder only warns about a missing input, so without a beforePack
guard a release ships an app that advertises the bundle capability and then
errors on every request. The hash check, not the existence check, is what
catches a half-written or stale out/.
The source tree is excluded from app.asar; out/mobile-web ships inside it under
the existing out rules, exactly as out/web does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-web): narrow the manifest with `in` instead of a cast
The changed-code casting gate rejects assertions, and `in` narrows the same
untrusted JSON without one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web): move the bundle source under src/ so the root guard passes
.github/scripts/check-root-directory-entries.mjs blocks any new top-level entry
by name, so mobile-web/ could not live at the root.
The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the
explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is
unaffected and still ships under the out rules like out/web. No tsconfig
includes src/**, so node, web, cli, and relay do not pick the tree up; it is
registered as a knip entry so audit:dead-code does not call it unused.
buildId is unchanged at 9d78435e: the builder hashes content, not paths.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(build): resolve the entry-script guard through pathToFileURL
`file://${process.argv[1]}` never equals import.meta.url on Windows, where that
url is file:///C:/... So the builder exited 0 having written nothing and the
Windows packaging job failed later, at the guard, with no clue why. Every other
script in config/scripts already uses pathToFileURL; this one now does too, via
an exported predicate a posix runner can exercise with a win32 path.
The verify script had no entry guard at all, so importing its budget constants
ran the whole verification — including its process.exit — inside the test
worker. It is now a function behind the same guard.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(ci): build the mobile web bundle in the PR package job
That job assembles packaging inputs step by step instead of calling
build:release, so the new beforePack guard hard-failed it.
The census test added here is the oracle: it walks every workflow job that
invokes electron-builder without --prepackaged (which short-circuits doPack
before beforePack) and requires a bundle-producing script in the same job. It
goes red on exactly pr.yml's package job when this step is removed. Ten jobs
covered; the other nine already ran build:release, build:release:parallel, or
build:desktop.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web): pin source line endings, because CRLF changes the buildId
Every text byte under src/mobile-web is hashed into an asset digest and from
there into buildId, so a CRLF checkout produces a different bundle id for the
same commit: 91af2897 instead of 9d78435e. That would make a Windows-built
desktop disagree with a mac-built one about which bundle a phone has cached.
.gitattributes pins eol=lf for the text sources and -text for the PNG, matching
the four trees already pinned for byte-hashing. The verify script asserts no
source file carries a CR, so the build fails if the pin ever stops applying
rather than silently shipping a second bundle identity.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(build): read the test's own path from import.meta.filename
oxlint unicorn/prefer-import-meta-properties.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(test): census packaging jobs over raw workflow text, not re-serialized YAML
yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the
fold landed between `electron-builder` and `--config`, so a real packaging job was
invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source
by its parsed boundaries instead, and pin the inventory so a new packaging workflow
has to be added here on purpose.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(build): assert the script chain the packaging census trusts
The census only checks that a packaging job invokes one of ten build scripts; that
those scripts still reach build:mobile-web was asserted nowhere, so a dropped link
would leave every job looking covered while packaging failed at beforePack. Resolve
each script for real, and pin pr.yml's hand-rolled step, since that job never calls
build:release.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(build): realpath the entry path before the direct-invocation compare
Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...`
against a /private/tmp realpath compared two different strings: the builder and the
verifier exited 0 having written and checked nothing. Same silent-success shape as
the Windows file:// bug, so the fix sits next to it, with both seams injectable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile-web): format bootstrap.css with oxfmt
It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at
9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's
CSS minifier normalises the whitespace this touches before the asset is hashed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(packaging): reject bundle files the manifest does not list
The guard only walked the manifest, so a dropped assets/stale.js passed: assets are
content-addressed, nothing ever overwrites a stale copy, and it would ship inside
asar unreachable and unverified. Require every file under out/mobile-web to be the
manifest or a listed asset.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(packaging): give beforePack an explicit mobile web bundle root
The bundle guard read the repo's out/mobile-web unconditionally, so the two
arch-aware packaging tests that call the real beforePack went red in the unit-test
job, which never runs build:mobile-web. beforePack now takes the bundle root as a
second parameter defaulting to out/mobile-web, which is what electron-builder gets,
and those tests build a real bundle into a temp dir instead. The guard is neither
skipped nor made tolerant of a missing bundle.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(packaging): census sees script-wrapped packers; dev verify reuses the guard
The workflow census only matched a literal `electron-builder --config` line, so
daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was
invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to
electron-builder without --prepackaged; the spike joins the pinned list (12 jobs).
verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard
(no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a
manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-web-bundle): add the manifest contract and content-addressed build id
The schema every later Phase A lane parses against: the ceilings that bound host
memory (256 assets, 32 MiB total, 10 MiB per asset), and a build id that is a
pure function of content so a client can use it as a cache key unconditionally.
The serializer sorts its input rather than trusting the caller, so a producer
that emits assets in any order still lands on the same id.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-web-bundle): add the bundle RPC payload contract
Method names, capability name, the 48 KiB chunk size, params/result schemas for
both methods, and the six error codes as a closed union pinned by a coverage
record. Constants and data only; the host wiring and the capability push land in
later lanes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): hash the build id without node:crypto
Metro ships no Node core shims, so a value import from these modules would fail
to bundle on the phone. The pure-JS sha256 keeps both contract modules
runtime-neutral, which also lets a cached manifest be re-verified on device.
Verified digest parity against node:crypto across the 55/56/64-byte padding
boundaries before the swap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): reject a manifest whose buildId is not its content hash
A stale id passed every other check and would then serve the wrong bytes under a
cache key the client already trusts. Runs last of the invariants because it is
the only one that hashes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): require a lowercase content type
The pattern carried an `i` flag over lowercase character classes, so the same
bytes described as `Text/HTML` and `text/html` produced two different build ids.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-web-bundle): move the capability name to a zod-free module
A4 wires this constant into protocol-version.ts, which the phone reads on the
capability path. Leaving it in the schema module would have dragged zod along
with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-web-bundle): name the chunk reply's length assetByteLength
It is the whole asset's length, not the chunk's, and sitting beside dataBase64
under the old name it read as the chunk's. Both are non-negative integers, so a
producer that emitted the wrong one would only surface at the final hash check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): reject asset paths that are not portable or that fold together
Two paths differing only in case are one file on macOS and Windows, so the host
would serve the same bytes under two entries and one of the two hashes could
never match. Windows-reserved segment names and trailing dots cannot be written
to the bundle root at all.
Both follow skill-package-manifest's checks, the folded-path Set and the
reserved-segment pattern.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): accept one spelling of a parameterised content type
The optional space in `; ?charset=` let the same bytes carry two content types
and therefore two build ids. Pinned to the single-space form the bundle builder
emits.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(mobile-web-bundle): stop hashing a manifest a cheaper invariant already rejected
zod runs superRefine even after the asset-array ceiling has failed, so a 257
asset manifest was still sorted and hashed. Each invariant now returns on its
own issue and the count is checked first, which is what the comment claimed.
The tests read the issue paths: an oversized or otherwise invalid manifest with
a deliberately wrong buildId reports no buildId issue, while the same wrong id
inside the ceiling does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile-web-bundle): say that the manifest has no additive path
`.strict()` plus a literal schemaVersion closes the shape completely, so the
version bump is the only way to change it. The phone value-imports this schema,
so Phase B must read an unrecognised schemaVersion as a bundle to re-fetch
rather than as a parse crash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(relay-ops): roll a cell a wave stranded after its drain
A wave that stops any time after its drain leaves the cell migration-only and
draining on the rollback image, and nothing clears it: the drain flag is a
one-way latch on the running process, and the failsafe restarts nothing. Both
recovery modes then refuse the cell. Apply wants it general and not draining.
Rollback sees the rollback image, reads it as a resume, refuses the draining,
and would not have restarted it anyway.
The image alone cannot separate a rollback that failed after its template apply
from a wave that stopped before one. The restart can: the first left a fresh
process, the second did not. Classify on that, so the cell that never restarted
takes the rolling path instead of the resuming one.
Its template still carries the image it serves, so that is the predecessor its
plan is reviewed against, and a template already moved on to the target is
refused rather than rolled backwards under a stale review. When the reviewed
template is already in place the plan changes nothing, so the MIG is rolled
explicitly on the same replacement policy a template change uses; the existing
incarnation check is what proves the instance came back.
Every other combination of mode, live image, and drain flag keeps the value it
had, held by a census that runs the real block over all nine.
* fix(relay-ops): pin the replacement method on the explicit MIG roll
gcloud persists every rolling-action bound into the group's update policy, and
it defaults the replacement method to substitute on a group with no stateful
config. Passing surge and unavailable without the method would patch the policy
off the declared RECREATE, and the next targeted plan would then carry a MIG
change outside version.0.instance_template, which the plan validator refuses.
Pass all three so the patch is identical to the declared policy, and read the
declared values in the census instead of restating two of them. Dropping the
flag, or moving any of the three in Terraform, now fails the census.
c17's canary stopped at the pre-apply predecessor check with
`runtime predecessor mismatch fields=draining`. The flag is residue: the
previous canary (run 35290908836) drained c17 at 00:26:01, its terraform apply
then failed, and the failsafe re-isolates without restarting the VM, so nothing
cleared it. The same run had passed this very check a second earlier, which is
what proves a parked cell is not draining at rest.
Draining means connections are being shed, and a migration-only cell holds
none, so the flag is not a precondition there. Accept it on entry for that
class only. The replacement VM is still required not to be draining, on every
path, and the incarnation check still proves it was replaced.
Both predecessor checks now read one decision instead of computing the rule
twice, so the assertion and its diagnostic cannot disagree. Every general-cell
and rollback path keeps the value it had; a census test runs the real block
over all eight mode and class combinations to hold that.
c17's canary-apply failed closed at plan validation. Its instance template is
from 2026-08-07 and predates the ORCA_RELAY_CAPACITY_SERVICE_ACCOUNT line that
every cell rolled since already carries, so the plan legitimately added it. The
same-cap validator holds the whole startup script identical before and after
except the image, and that line is not one it excluded, so the wave stopped
with nothing applied.
Pin the line for same-cap-cell exactly as bootstrap-cell already does, and
exclude it from the before/after comparison. The cell may gain it; the pin is
what refuses a roll that drops it or rewrites it to another identity. Both plan
validations in the job now pass the capacity identity the job already requires.
The same-cap contract is otherwise unchanged: any other stale line still fails
closed, and needs a convergence apply before the cell can roll.
`readProjectedConflictOperation` and `readProjectedCompareStatus` constructed
a `z.enum` on every call, so every `git.status` and `git.branchCompare` reply
paid the constructor. Hoisted to module constants; the git-status payload
schema reuses the same instance. Behaviour is unchanged: same arms, same
fallbacks, identical reader output on all eleven recorded matrix cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A batch-apply wave verified only that the sealed canary named some approved
same-cap cell, so a canary rolled on the migration-only, zero-host, 600-cap
c17 or c18 was accepted as authority for a general 1000/3000-cap batch. The
verify step now hands the batch's own cells to the check, which requires the
sealed cell's entry admission to equal the batch's class.
c17 and c18 hold no hosts and sit outside general admission, so rolling one
displaces nobody. They are the only zero-displacement canary for a new cell
image, but the same-cap wave refused them at the dispatch validator and would
have promoted them to general at the end if it had not.
Add them to the approved list and teach the wave a cell's entry admission
class: the precheck demands the class the cell is declared to serve in, the
restore hands it back that class, the isolate on an already-isolated cell is
asserted to change nothing, and the selector generation advances by 2 for a
general cell and by 0 for a migration-only one. One wave may not mix the two,
because every cell after the first offsets from a single per-wave delta.
Neither cell is a declared regional-rehome source, so its template carries no
rehome trust lines. The source-membership guard now fires exactly when a roll
expects those lines instead of for every US cell, which is the invariant it
was standing in for, and which limits c17 and c18 to rehome protocol 0.
* feat(agent-launch): deliver a launch prompt from the host
`agent.launch` created the surface and then reported the caller's text as
`not-delivered`, always: delivery lived in the renderer, so mobile and any
other caller got an agent and no prompt.
The host now commits a `submit` prompt to the structured session it just
created, through the same send path `agentSession.send` runs, and reports
`journaled` with the transcript row's id. Nothing is queued — the durable
record that the text is owed is the journal's own submission row, which the
send appends before dispatching, so a host-side copy could only disagree with
it. The outbox's entry and envelope builders are reused so this send is shaped
exactly like a client's, fingerprint included.
Everything else under-claims as `not-delivered`: a terminal's paste is
observed by whoever owns the pane, a `draft` has no host-side home, and a
refused or thrown send commits nothing. There is no fourth "maybe" arm — a
caller holding one could neither resend nor drop the text — and dispatch doubt
stays on the submission row where it already lives.
* fix(agent-launch): recover committed prompt after send errors
* perf(relay): batch control lease renewals per cell instead of one write transaction per host
Every connected desktop renewed its own control lease with its own single-row
write transaction every 30s. At ~14,000 hosts that is ~470 write transactions
per second fleet-wide, each with its own transaction id, all updating the same
few heap pages of relay_assignments and relay_assignment_activity_leases.
Sampling three onsets at 250ms showed no lock queue and no slow statement:
60-144 backends piled into LWLock:BufferContent and Timeout/SpinDelay inside
that one statement, and Query Insights attributed 152 of 157 seconds of
lightweight-lock wait in the onset minute to it.
The heartbeat now enqueues a due renewal instead of issuing it. A cell flushes
its queue once per second, or as soon as 500 rows are waiting, through one
statement that unnests the parameter arrays and applies the same CTE row-wise.
Concurrent writers drop from the host count to the cell count, and transaction
ids with them. Measured against a 20,000-row table: 1 row 4.1ms, 12 rows
3.3ms, 100 rows 6.6ms, 500 rows 22.7ms.
Per-session semantics are unchanged. Each enqueue still resolves on a renewal
and rejects with the outcome as its message, so the completed-attempt counter,
the staleness guard, and every close path route exactly as before, and one
outcome per row is recorded against the flush latency.
Lock order is (user_id, relay_host_id), the primary key of relay_assignments,
applied in JavaScript and repeated as the statement's ORDER BY. EXPLAIN
confirms LockRows sits above that Sort, so a batch acquires its assignment rows
in one global order. Every writer in the store locks a host's assignment row
before its migration or lease rows and only ever touches one host, so a batch
can only wait on a row a single-host writer holds, never the reverse.
One statement also means one contended row could fail the whole batch, so a
failed batch degrades to the per-host statements it replaced rather than
costing every other host on the cell its renewal.
* perf(relay): batch control lease renewals per cell instead of one write transaction per host
Every connected desktop renewed its own control lease with its own single-row
write transaction every 30s. At ~14,000 hosts that is ~470 write transactions
per second fleet-wide, each with its own transaction id, all updating the same
few heap pages of relay_assignments and relay_assignment_activity_leases.
Sampling three onsets at 250ms showed no lock queue and no slow statement:
60-144 backends piled into LWLock:BufferContent and Timeout/SpinDelay inside
that one statement, and Query Insights attributed 152 of 157 seconds of
lightweight-lock wait in the onset minute to it.
The heartbeat now enqueues a due renewal instead of issuing it. A cell flushes
its queue every second, or as soon as 200 rows are waiting, through one
statement that unnests the parameter arrays and applies the same CTE row-wise.
Concurrent writers drop from the host count to the cell count, and transaction
ids with them. Per-host buffer traffic is unchanged: 30 hits for one row, 25.5
per host at 12 rows, 30.1 per host at 200, against the 28.7 the single-row
statement reports in production.
Per-session semantics are unchanged. Each enqueue still resolves on a renewal
and rejects with the outcome as its message, so the completed-attempt counter,
the staleness guard, and every close path route as before, and one outcome per
row is recorded against the flush latency.
Row locks live until the statement commits, so a batch that waited on a
contended row would hold every other row's lock for that whole wait. The
assignment pass therefore takes its locks with SKIP LOCKED and reports a
contended host as assignment_lock_unavailable, which the registry retries on the
next tick instead of closing the control. That keeps the hold to the statement's
own execution: 11.5ms for 200 rows against a 20,000-row table, and 9.4ms with a
host wedged in a per-host transaction, where a blocking FOR UPDATE spends the
pool's whole 1s lock_timeout and then fails every row in the flush.
An unlocked present_assignment probe separates a host with no assignment row
from one the skip passed over, so a skipped row can never be mistaken for a
missing assignment and close a live desktop.
With no wait on the assignment pass the lock order is only needed for the two
later passes, and it holds: every writer takes a host's assignment row before
that host's lease rows, and a host whose assignment row is held was skipped, so
the batch never reaches its lease. markMigrationTargetRegistered is the one
writer that locks a migration row first, and it takes no further locks.
* fix(relay): answer every row of a control-renewal batch from its own lease update
Review findings on the batched renewal.
Two control leases on one host in one batch made the second report
control_activity_not_found although both were renewed: the assignment UPDATE is
offered the same target row twice, applies one source row and returns one, so
the other row_index never came back. The verdict now reads renewed_lease, which
has a row per input row, and the assignment UPDATE groups per host so it also
stops taking an arbitrary one of the two expiries instead of the later one.
The queue now partitions per (userId, relayHostId) rather than per activity, so
a second control activity for one host opens the next flush instead of sharing
this one. Belt to the statement fix, not a substitute: the store API has to be
right for the rows it accepts.
renewControlActivities recorded no outcome for a one-row flush that threw, and
none at all when every row failed validation, where a mixed batch recorded its
invalid_* rows. Both now record in a finally, the way the single-row path's
finally always did, and the error-to-outcome mapping both paths share is one
function.
The four flush fields the runtime metrics event emits had no log-based metric,
so add them next to the existing controlRenewalLatencyMs* entries. Applying the
Terraform is a separate manual step.
controlRenewalLatencyMsP50/P95/Max now measure a batched row's flush duration
rather than its own statement latency. Left named as they are for history, with
a line at the emit site recording that the meaning changed here.
Added in #21301 on the reasoning that the composite (active, deadline)
index spans all 6.65M rows to find a few hundred live ones. Measured
post-merge against production-shaped history, that reasoning does not
hold: a basis is inserted active and flipped to 0, so the partial index
accumulates one dead entry per deactivation exactly as the composite one
does. Scan buffers are identical to composite-only in every state, 11,099
cold, 2,522 warm, 9 after VACUUM, and the planner picks the composite
index throughout. The extra index costs ~65 bytes of WAL per basis insert,
about 22% more.
The relief is the reaper plus vacuum, which #21301 already ships. Removes
the statement from SCHEMA, restores the plan test to pinning the composite
index by name, and records why a narrower index is not the cure so the
next reader does not re-derive it.
* fix(mobile): move the last six reply-enum pins where tsc looks
mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.
Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:
- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy
openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.
Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fix the type errors in eighteen test files
Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:
- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
where act wants void, so each becomes a block. The async ones await only a
genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
the branch-compare summary's errorMessage, and SessionOptionDescriptor's
transport, which #20884 added precisely so a producer could not inherit the
wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
becomes `advance?.kind`, one widened status arm takes `as const`, and the
Expo notification fixture keeps `data` required because the dismissal cases
assign through it.
631 test files pass, 6222 tests, unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): typecheck the test files, on a ratchet
mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.
tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.
The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.
Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile-recorder): correct the corpus counts and the salvage claim
The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.
"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the tests-typecheck ratchet's parser
The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.
Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): await the timer advances the act() rewrite dropped
Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unshadow MobileHostCard's .tsx suite
A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): census every test file into the typecheck program
The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(shared): make the enum helpers refuse the ways they can prove nothing
openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): describe the census and correct the baseline count
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the push fixture cast its SAFETY rationale
Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build the push fixtures as typed notifications
Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the grouped-question advance read non-optional
`advance?.kind` let an absent advance take the null-draft branch instead
of failing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): run the tests-typecheck ratchet on Windows
Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once
tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.
`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(relay): index and gate the credential cleanup sweeps that seq-scan relay_invites
The credential cleanup ran every 30s in all 23 cells as well as the
director. Both of its relay_invites passes matched columns no index
covered, so each one seq-scanned the whole table inside the maintenance
transaction: 56 calls/min fleet-wide, 129ms and 63ms typical and 57s at
the tail, to return about one row every nine minutes.
Adds partial indexes matching each sweep predicate, gives the cleanup the
same owner as the assignment sweep, and reaps terminal invites after
seven days so the table stops growing for the life of the database.
Every index carries the schema-deferrable marker: an operator builds them
with CREATE INDEX CONCURRENTLY, and the catalog pre-check skips them from
then on.
* perf(relay): index live bases and reap settled connection authorizations
relay_connection_bases is the dominant cost in the cleanup transaction:
195 ms of the 268 ms average, with ~5,800 shared buffer hits per call
even though it already uses relay_connection_bases_active_deadline. That
index spans all 6.65M rows, and only a few hundred are ever active.
Adds a partial index on the live rows alone, and reaps settled rows from
relay_connection_bases and relay_direct_authorizations once their deadline
is more than a day past. Both readers of either table require the row
active/unconsumed and inside its deadline, and every deadline is set at
most 30s past insert, so a settled row can never authorize anything again.
The composite index stays: it is the only one covering active = 0, and it
is what lets the drained reaper learn there is nothing to do from the index
rather than the 1.5 GB heap. Measured at 200k rows, 5 buffers with it and
1,274 without.
* test(relay): accept either bases index in the sweep plan assertion
The negative assertion pinned a planner choice rather than the invariant:
either index keeps the sweep off the 1.5 GB heap, and which one wins on
cost is not something the test should fix. Matches how the same file
already handles the two invite sweep indexes.
Also names the column the authorization reaper actually measures, which
is consumed_at rather than deadline.
* refactor(mobile): put the branch-compare leg on the lifecycle owner (step 5)
The compare kept three hand-rolled guards for one reply, combined in an
`isCurrentLoad()` the four exit points each had to remember to call:
`branchCompareGenerationRef` (latest-wins), `currentBranchCompareIdentityRef`
(the route identity, written in render) and `mountedRef`.
The owner replaces the first two. An attempt now `reset()`s and then `load`s, so
the newest attempt is the only one holding a live lease, and the reply is
published only through `commit(lease, value)`. What retires a compare is named
at the call site: this host, this route identity, this workspace.
A compare is a refresh, so neither of the owner's other two mechanisms applies
here and the `reset()` before each `load` is what says so: nothing it holds is
reusable, and no attempt may share its predecessor's reply. Dropping that line
makes the second attempt join the first's request and publish a base ref the
user already navigated away from.
The identity retire moves into the render-phase adjust-on-prop-change block,
where the identity ref was written. Leaving it to the next load's scope is not
the same thing: that load only starts once the fresh `git.status` returns, and
an in-flight compare would publish the old worktree's commits first.
`mountedRef` stays. A detached route has no screen to publish to, which is a
fact about the view, not about which reply is current.
The three decision points that used to write state mid-flight — no base ref, a
refused capability, an unreadable reply — are a returned `BranchCompareOutcome`
now, so the loader body writes nothing and the screen is written in one place.
That also puts this file under the loader-write source fence.
No golden moves: the recording suites reproduce byte for byte.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct the compare scope comment to the one call that reads it
The pilot's wording named two scope consumers; the compare leg has only `load`.
What the scope still adds over the render-phase retire is the structural half: a
scope the owner has not seen retires on its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the lifecycle owner's loader a currency probe
A loader that spans two round trips had no way to ask whether its scope
had moved, so a superseded attempt sent its second request and was only
refused at commit. The probe answers exactly the question commit asks and
carries nothing to publish with, so the owner's publish fence is unchanged:
a loader that stops on it returns null, which the owner already reads as
no value.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep a superseded branch compare off the wire
Restores request-count parity with main for the one path the migration
changed: an attempt superseded while it resolved its base ref used to stop
before sending git.branchCompare, and under the owner it sent one and was
refused at commit. It now stops on the owner's currency probe between the
two legs, so the screen is unchanged and so is the request count.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the probe's missing generation actually is
Stripping the directive gives TS2339, a member that does not exist, not a
privacy error: the probe has no generation to keep private.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin that a detached route sends no compare
The detach reset() was the only thing retiring an attempt after the route
went away, and deleting it left the suite green. This schedule detaches
mid base-ref lookup and asserts nothing reaches git.branchCompare; without
the reset() it fails with one request sent.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the scope member the identity key already carries
statusIdentityKey is `${hostId}\0${worktreeId}`, so listing worktreeId
beside it read as a third fence when it fences nothing new.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): split the compare protocol out of the loaders hook
The outcome union, the attempt and the screen mapping are the compare
leg's own protocol, not the hook's: nothing in them reaches React. Moved
verbatim to mobile-branch-compare-outcome.ts with a unit pin for the
mapping, which only the hook's schedules covered before. The hook drops
from 283 to 230 lines against a 300 limit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): correct the joiner comment and narrow the compare sender
A joiner never receives the probe: its fn is never invoked, it awaits the
originating request's promise, and retire() clears inFlight so none can
join across a generation bump. The compare attempt takes the operation
sender the convention names rather than a whole RpcClient, which it only
ever used as that.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(perf): pin the pane-title global-scan repro at live-capture scale
One setRuntimePaneTitle at Jinjing's scale (~870 workspaces, 1,408 terminal
tabs, 857 sleeping records, 20 mounted worktrees) reads 19,711 sleeping-agent
records: 23 executions of selectSleepingRecordParkExemptTabIds x 857. The two
budget cases are it.fails so the before-state lands in history.
Refs STA-7552, STA-7551
* perf(renderer): memoize the sleeping-record park exemption on slice identity
A pane title update writes runtimePaneTitlesByTabId, but zustand re-runs every
mounted subscriber's selector, so each retained worktree walked the whole
sleeping-agent inventory to conclude nothing changed for it. useShallow
suppressed the re-render, never the scan.
selectSleepingRecordParkExemptTabIds now goes through the existing
createWorktreeRecordSelector generation cache, keyed on the record-map
identity, so the walk happens once per worktree per real inventory change
instead of once per store write. The cache moves from components/sidebar to
store/ now that terminal-pane shares it, and takes an isEmpty override so a
Set-valued selector can use it.
19,711 sleeping-record reads -> 0 for one title update at capture scale.
Refs STA-7552, STA-7551
* test(perf): model the full sidebar fanout and count all four axes
The first repro mounted 20 retained workspaces (~60 subscribers) and counted
record reads only, which under-models the capture. The sidebar worktree list is
not virtualised, so all 870 rows mount and each WorktreeCardStatusSlot opens
~6 subscriptions. Mounting the real row component brings the harness to 5,500
zustand listeners, inside the capture's 5,462-7,478.
One setRuntimePaneTitle now reports listener invocations, per-module selector
executions, React commits, and records scanned. Before/after the memo, only
records scanned moves: 19,711 -> 0. Notification work stays O(mounted
workspaces) by construction; each visit is now an identity check.
Refs STA-7552, STA-7551
* test(perf): count sidebar-row commits inside the row subtree
* refactor(store): teach the selector cache Set/Map emptiness instead of an option
* test(perf): drop the duplicate mounts and unasserted counters
* refactor(terminal-pane): tighten the park-exemption selector's shape and why
* refactor(store): keep the emptiness check off the broad object type
* test(renderer): count the three instrumented selector modules in the fanout comment