mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
debug-github-rate-limit
11496
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41059f65b2 |
fix(pty): reconcile daemon exits after synthetic notifications (#21000)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
b4a6e2a80a |
fix(filesystem): match allowed roots across Unicode forms (#21194)
* 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. |
||
|
|
54e11473a6 |
fix(browser): fence late registration replies to their guest owner (#21012)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
a037180630 |
fix(ai-vault): release retired search write fences (#20986)
* fix(ai-vault): release retired search write fences * test(ai-vault): use checked search writer mocks * test: use typed access in memory retention regressions --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
54500a4281 |
Release hang watchdog quit listener on shutdown (#20910)
Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
9ed2f743a4 |
fix(runtime): fence terminal snapshot completion by owner (#20996)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
78289d8ebe |
fix: release settled browser results after dispatcher close (#21164)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
98998b18ad |
fix: release retired shared daemon owner metadata (#21162)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
1d09d55787 |
fix: fence viewport state after browser guest retirement (#21160)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
14654d03cb |
fix: release completed SSH writer queue entries (#21150)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
ab331253a0 |
fix: release canceled working-directory waiter references (#21144)
* fix: release canceled working-directory waiter references * test: normalize working-directory proof patch --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
3c138bd863 |
Skip empty chunks in streamed agent text (#21142)
* fix: skip empty chunks in streamed agent text * test: lint empty-delta retention reproducer --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
b899b22545 |
fix: release native PTY spawn environment after setup (#21140)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
79800e60b4 |
fix: release completed terminal spawn inputs (#21139)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
fbfe3a2e74 |
fix: release Codex prompt claims when their turns complete (#21138)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
51f809aa82 |
fix: retire obsolete GitLab host cache generations (#21136)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
f90370fb6b |
fix: detach aborted shared auth filesystem waits (#21135)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
0e3acf577d |
fix: release consumed runtime RPC queue entries (#21131)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
bdad0e0f00 |
fix(browser): release page callbacks when a guest is destroyed (#21010)
* fix(browser): release page callbacks when a guest is destroyed * fix: address memory PR review regressions and withdraw false positives --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
e9c04fb8d9 |
fix(ai-vault): ignore cancellations after request settlement (#20980)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
df88f83c70 |
fix(relay): bound descendant traversal on cyclic process snapshots (#20946)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
bd404185f1 |
fix(renderer): release parked terminal scroll intents (#20924)
* fix: release scroll intents for closed parked tabs * fix(renderer): release scroll intents on worktree removal * test(renderer): cover parked worktree intent cleanup --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
d7d3bcfc66 |
fix(renderer): cancel copied prompt reset on unmount (#20906)
* fix(renderer): cancel copied prompt reset on unmount * fix: address memory PR review regressions and withdraw false positives --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
d3032da299 |
fix(renderer): cancel signout auth retry on unmount (#20905)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
9c92136009 |
fix(relay): probe a half-open control socket instead of waiting out the silence bound (STA-7672) (#21076)
* 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.
|
||
|
|
c45b2c94c6 |
fix: make worktree scan failures actionable (#21291)
* fix: make worktree scan failures actionable * fix: preserve remote worktree scan diagnostics |
||
|
|
ad4f26cdd4 |
feat(build): build, verify and package the mobile web bundle with every desktop release (OTA phase A, 2/5) (#21326)
* 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 |
||
|
|
f2ca3cbfb7 |
feat(mobile-web-bundle): manifest and RPC contract for the desktop-served mobile web bundle (OTA phase A, 1/5) (#21325)
* 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 |
||
|
|
f442a5c484 | fix(native-chat): hide legacy resume command for structured history (#21282) | ||
|
|
6c913a917f |
fix(relay-ops): roll a cell a wave stranded after its drain (#21321)
* 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. |
||
|
|
27bddc6198 |
fix(relay-ops): accept a drained predecessor on a cell that holds no hosts (#21315)
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. |
||
|
|
acedcf2a97 |
fix(relay-ops): pin the capacity identity so a stale same-cap template can roll (#21314)
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. |
||
|
|
1cd2964501 |
perf(mobile): build the two projected git enums once, not per parse (#21311)
`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 |
||
|
|
ff8f7085cc |
fix(relay-ops): bind the canary cell's admission class into same-cap batch authority (#21313)
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. |
||
|
|
399306c171 |
feat(relay-ops): allow the migration-only cells c17 and c18 in same-cap waves (#21307)
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. |
||
|
|
754134fd67 |
feat(agent-launch): deliver a launch prompt from the host (#21155)
* 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 |
||
|
|
91ade4b82a |
perf(relay): batch control lease renewals per cell instead of one write transaction per host (#21303)
* 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. |
||
|
|
ddbad2218b |
chore(relay): drop the live-basis partial index that the planner never picks (#21305)
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. |
||
|
|
40b2230508 |
test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* 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 |
||
|
|
cbd04704d6 |
perf(relay): index and gate the credential cleanup sweeps that seq-scan relay_invites (#21301)
* 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. |
||
|
|
49274394fc |
refactor(mobile): put the branch-compare leg on the lifecycle owner, with a currency probe (step 5) (#21299)
* 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 |
||
|
|
03714183b8 |
perf(renderer): stop one pane title update from scanning the global sleeping-record inventory (#21292)
* 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 |
||
|
|
eabfbaab88 |
refactor(mobile): drop the unreachable dispose-before-ready notifications arm (#21293)
* test(mobile): pin the desktop-notification dispose-before-ready contract Drives `subscribeToDesktopNotifications` through the real `RpcClientStreamRegistry` so the disposer's effect on a later `ready` reply is stated rather than implied. Both cases pass against the current module, before any code is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the unreachable dispose-before-ready notifications arm `disposed` is set only on the first line of the disposer, whose next statement detaches the stream listener in every transport, so the `ready` arm can never observe it. Removing the branch changes no behaviour and moves no golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin cancel fencing in the relay and logical stream layers The notifications comment claims every transport detaches a listener inside its disposer, but only the stream registry was pinned. Adds the same live/cancelled differential pair to the relay stream manager and the logical client, the latter against a physical session with an inert disposer so only the logical guard can fence the late event. Drops a self-comparing assertion to a length check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the notifications registry fake instead of asserting it The changed-code quality gate rejected three `as` casts. The fake client is now declared `RpcClient`, so the compiler checks it really satisfies the port, and the registry's `unknown` send port is narrowed by a reader that throws on a frame without a string id and method rather than asserting one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a634bf9b49 |
test(bench): runtime-graph publication probe and optional CDP CPU throttle (#21107)
* test(bench): count runtime-graph publications from main The build-provided `__orcaBenchmarkInstrumentation` is gone from the tree, so the typing bench could no longer report graph-publication counts at all. The renderer cannot supply them either: `window.api` is frozen by contextBridge, so `runtime.syncWindowGraph` is not wrappable. Count them where they land instead — main's `runtime:syncWindowGraph` invoke handler — behind ORCA_TYPING_BENCH_GRAPH_PROBE=1, and record the result in the bench report. Measured on an 870-worktree fixture: 21 publications over a 50 s metadata-only window versus ~1,205 with recurring OSC title/status traffic. The long-task fields ship unproven: an injected 250 ms renderer busy-wait produced zero entries even though `longtask` is in `supportedEntryTypes`, so their zeros mean "oracle unverified", not "no long task". The self-test knob exists to make that falsifiable, and the file says so; per-publication build time still needs a separate --cpu-profile run. * test(bench): optional CDP CPU throttle around the typing window * test(bench): report the throttle that ran and the long task the self-test caused Two ways the bench could misreport its own conditions. `cpuThrottleRate` was the requested rate, written into every report, but only two of the three scenarios wrapped their typing window in the throttle — a `--cpu-throttle 4` visible-split run claimed a 4x throttle it never applied. Recording the rate per scenario would have made the report honest; it would also have left one scenario silently ignoring the flag, and a fourth scenario would inherit the same omission. So both: every scenario now goes through one `measureTypingWindow` helper, and the value it returns is the rate the throttle actually applied. `writeBenchReport` takes that composite instead of a bare measurement, so a scenario cannot produce a report without saying what it ran under. Unthrottled runs are unchanged — rate 1 still opens no CDP session. `selfTestLongTaskMs` took the *earliest* long task starting before a cutoff captured after the busy-wait. The observer has been live since probe start, so any unrelated long task from fixture setup satisfied it — the field whose whole job is to prove the oracle is live was the easiest one to fake. The busy-wait now reports its own renderer-clock bounds and the matching entry is the one containing their midpoint: main-thread tasks never overlap, so at most one can, and it is the task the busy-wait ran in. That entry is then withheld from `longTasks`, `longestLongTasks`, and `longTasksAroundPublication`, which had been counting the oracle's injected 250 ms as workload. A zero still means "oracle unproven" — it now also means it honestly. * test(bench): stop the graph probe when the typing run throws * test(e2e): drain queued long-task records before the probe disconnects |
||
|
|
8e8a9b38ea |
perf(relay): stop indexing the column every control renewal writes (#21286)
* perf(relay): stop indexing the column every control renewal writes relay_assignment_activity_expiry indexes expires_at on relay_assignment_activity_leases, and expires_at is what every control renewal updates: ~471 calls/s, all of them non-HOT because a changed indexed column forbids HOT. The index has one reader, the 30s expiry sweep, which seq-scans the whole 14.8k-row table in under a millisecond. Drop it, and set fillfactor to 70 so a renewal has room for a second row version on its own page. Measured on postgres:16-alpine over 14.8k rows, WAL bytes per renewal and HOT ratio: index, fillfactor 100 (today) 0% HOT 371 B index, fillfactor 70 0% HOT 246 B no index, fillfactor 100 0.5% HOT 298 B no index, fillfactor 70 100% HOT 80 B Both are needed: the index makes HOT illegal, and the default fillfactor leaves no page space to make it possible. Neither statement can use the catalog pre-check as it stood. DROP INDEX IF EXISTS resolves the name before it locks, so once the index is gone it costs a catalog miss and takes no lock on the table - pinned in the lock-target census as the one exempt statement. ALTER TABLE SET does take a lock, so it gets a new 'reloption' target kind that asks pg_class.reloptions for the name=value pair, keeping the invariant that no lock-taking statement reaches a warm boot unchecked. * fix(relay): pre-check the activity-expiry drop and let it defer on a lock timeout The drop had no catalog pre-check, so it was sent on every boot, and a 55P03 from it was fatal: apply-postgres-schema throws on a lock timeout with no retry. On the migration boot that combination is a crash loop. All 28 directors reach the same DROP INDEX at once, it needs ACCESS EXCLUSIVE on a table written ~475/s with lock_timeout at 1s, and a boot that fails restarts the instance to re-queue the same DDL behind the same writers. Two changes: - A new 'index-by-name' lock target. A DROP INDEX names no table, so the existing index check could not serve it; this one resolves by name through the search_path with relkind = 'i', which is how the DROP itself resolves, and skips when absent. DROP INDEX now counts as lock-taking in the census, so it is covered rather than exempt, and IF EXISTS is required the way it is on DROP CONSTRAINT. - A 'schema-deferrable' marker, read from a statement's leading comment. A 55P03 on a marked statement logs orca_relay_postgres_schema_object_deferred and leaves the statement unapplied instead of failing the boot; the next boot re-sends it. Both activity-lease migrations carry it. Everything else keeps the old contract and still fails loudly. SchemaApplySummary gains a deferred count so a boot that skipped work is distinguishable from one with nothing to do. Verified against a real server: with the index present and the table held in ACCESS EXCLUSIVE by another session, both statements defer, the boot completes, nothing is half-applied, and the next boot finishes the job. A warm boot now sends neither statement at all. |
||
|
|
0b1cde0e01 |
chore(mobile): repin the RPC recording baseline to main after #21269 (#21287)
The last step-7 squash orphaned the pin again. Repin to
|
||
|
|
0e7948fa6d |
feat(relay): pace the drain send during a same-cap cell roll (#21284)
* feat(relay): pace the drain send during a same-cap cell roll A same-cap roll drains a cell with graceMs 0, which sends `drain` to all ~800 controls in one pass. Every desktop re-dials on receipt regardless of graceMs, so the whole cell reconnects inside a second. On 2026-09-16 that stampede hit a Cloud SQL stall: attaches timed out, each leaving 10 minutes of late-arrival debt on connection headroom, and placement answered relay_capacity_exhausted fleet-wide for ~13 minutes. Spreading the sends spreads the re-dials. `HostSessionRegistry.drain` takes an optional pacing window and schedules each session's send evenly across it; admission is fenced for every session up front, and each host keeps its own full grace after its own send. /v1/admin/drain accepts `paceWindowMs` (<= 5 min) and echoes what it applied. The same-cap job asks for 120 s, and the drain-completion wait grew by the same amount. A cell still on an older image rejects the field, so the deploy script falls back to an unpaced drain rather than failing the roll. * fix(relay): scope the drain fence to the hosts already told Review of the paced drain found two problems, both from treating "this cell is draining" as one instant when pacing makes it a window. Timers: the sends queued by a paced drain were neither cleared when a later drain superseded them nor unref'd. A SIGTERM mid-window left up to 800 no-op timers holding the event loop open until systemd escalated to SIGKILL. Drain timers are now tracked, cleared on the next drain, and unref'd, so a retry re-arms a session's teardown instead of stacking a second one. Phones: the client fence read the global draining flag, so every phone was refused for the whole window even though its own host had not been told yet and was still serving. The director keeps pointing phones at this cell until their host moves, so they would have looped for up to two minutes. A session is now fenced when its drain is sent, not when the drain starts, and the client paths key off that. New control connections and re-attaches stay fenced globally: nothing new should land on a cell that is going away. |
||
|
|
4a86b2dc56 |
refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history (step 7) (#21269)
* test(mobile): record main's file-preview and markdown-disk-fallback replies
Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.
Three new families, five scenarios, ten goldens:
- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
`files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
`renderer_unavailable` sends the markdown tab down. It carries a second scenario
that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
recorded somewhere in its own family to replay as the `normal` partition.
No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff
|
||
|
|
9de6f2c6cd |
test(terminal): bump the pane hook-order parity pin past #9035 (#21276)
* test(terminal): bump the pane hook-order parity pin past #9035 #9035 added a useRef and a useCallback to use-terminal-pane-foundation (search input ref, focus-search-input) without moving the parity pin, and its own PR run never executed the shard that holds it. Every PR opened since fails `tests node 24 7/8` on `expected 211 to have a length of 209`. The two hooks are in order behind the existing ones and useMemo stays at 8. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(terminal): re-pin the hook-order hash for the two #9035 hooks The count alone was not the pin: the flattened order is hashed too. The new order is the old one with useRef and useCallback inserted at the foundation stage and nothing else moved (diffed before and after #9035). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b66ef2e8a8 |
fix(agent-launch): resolve a launch scope, not a git worktree record (#21193)
* fix(agent-launch): resolve a launch scope, not a git worktree record `agent.launch` asked the runtime for a managed worktree record and then read exactly one field off it, `.id`. That record does not exist for every workspace a launch can run in, so the request refused launches the method could otherwise run: the floating workspace resolves to a scope with an id and a path but no worktree row, and `showManagedTerminalWorkspace` throws `selector_not_found` rather than hand back the id it had already resolved. A folder workspace survived that only because the resolver fabricates a worktree row for it. The scope is the answer that is real for all three kinds, so the launch asks for that instead. `showManagedTerminalWorkspace` is unchanged - callers that genuinely need the git record still get it, and still get the refusal. With floating now reaching the mode decision, the host must know which kind of workspace it resolved. The kind is derived from the id it resolved itself, never accepted from a caller, and the route module's existing `floating` blocker does the rest: a workspace with nowhere to keep a session runs a terminal agent. Behaviour change, deliberate: a floating-workspace `agent.launch` used to fail with `selector_not_found` and now succeeds as a terminal agent. That is what lets the floating titlebar agent button move onto the shared launch command instead of driving tab startup itself. No wire change: `AgentLaunchTarget` is untouched. * test(agent-launch): cover floating RPC workspace resolution |