mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
db2ffe7afefea17d7e24f3ec1c5d1cd14f41fdde
11250
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
db2ffe7afe |
fix(mobile): default injected timers to receiver-free wrappers (#21416)
* fix(mobile): default injected timers to receiver-free wrappers Every transport class stored a global timer function on an object and then called it back through that object, so the receiver was the instance or the dependency bag rather than the global. Hermes ignores the receiver; browsers reject it with TypeError: Illegal invocation, which makes the web build fatal at the first retry, liveness probe, or relay grace timer. Default each injected timer to a wrapper that calls the global receiver-free, and narrow the seam's type from `typeof setTimeout` to the call signature it actually uses. Node's `typeof setTimeout` also demands a `__promisify__` member that no injected timer or wrapper can supply, so the wrapper cannot satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck baseline follows: the narrower type makes that file check clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the default timers against a browser receiver check Both classes are now constructed with no injected timers under a global setTimeout/clearTimeout that throws Illegal invocation for any explicit non-global receiver, mirroring the WebIDL rule. The watchdog gets its own file because its existing test is grandfathered out of the typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the default clear leg and drop bare timer injections The clear assertions were vacuous: cancel() and stop() also drop the state a fired callback checks, so a no-op default clearTimer stayed green. Both tests now assert the wrapped global clearTimeout received the exact handle setTimeout returned, which fails when that default is mutated to a no-op. Three relay tests injected bare setTimeout/clearTimeout into dependency bags, the same receiver shape the product fix removed; inert under node, fatal under jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout` casts, since ScheduleTimer now types those arrows contextually. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census bare global timers parked in properties and defaults mobile-endpoint-lifecycle could regress to bare globals with every other test green, because nothing there is reachable from a unit test. Walk every product file's AST and fail on a global timer parked where a later call reaches it through a receiver: a `??` or `||` default, an object literal member, or an assignment onto a property. A plain local capture stays legal, since calling it bare leaves the receiver undefined. A separate test asserts the walk sees the five fixed sites' wrapper shape, so an empty or misdirected scan fails instead of passing vacuously. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): define the receiver-free timer defaults once Five hand-written wrappers each restated the same invariant, so five places could drift. timer-scheduler now exports defaultScheduleTimer and defaultCancelTimer, and carries the reason for them; every site takes its default from there. The census keys its presence precondition on those two identifiers instead of the arrow shape. The census also missed `??=` and `||=`, which park a global exactly like their non-assigning forms. Both are handled now, with a parsed-source case per parking form and one for the local capture that stays legal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5c2d3322c1 |
fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped `buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose `paneKey` still parses to its own `tabId` passes that forever, including long after the session graph dropped the pane, so a terminal that had lost its surface reported `orphaned: false, connected: true, writable: true` and a `tabId` no tab has: field-for-field identical to a healthy one (#18191). Consult the leaf topology instead, gated on a graph statement having had the standing to contradict the record. `graphSequence` counts authoritative graph statements; every statement re-records the surface of every pane it publishes, so a pane the current graph holds carries the current stamp and is answered without touching the leaf map. That covers the two absences that are not evidence, without a second flag: a surface recorded since the last statement (spawn records the pane before the graph carrying it arrives, #7587), and a lost graph clearing every leaf at once without advancing the sequence. A pane already observed dropped keeps its stale stamp and stays named, because losing the ability to re-check is not a reason to un-see it. `orphaned: true` is shipped vocabulary that both consumers already read, so no capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and now reaches this population, and the duplicate-surface index (`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's live owner. * fix(runtime): publish a terminal retirement proof on the exit's own evidence A paired client may drop a mirrored terminal on exactly two kinds of host evidence: a `retiredTerminalSurfaces` proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The second needs two host publications, and a quiet workspace publishes one, so the proof is the only evidence that rides the frame carrying the retraction. That proof was minted only as a byproduct of persistence *accepting a change*, which made one value carry two meanings: "a change was accepted" and "the PTY exited". The host renderer's close transaction de-persists the surface and republishes without it, so when it got there first the exit found nothing left to accept and the attestation died with it. Measured on a real paired client: the host retracted in under 500ms, published no proof, then froze its snapshotVersion for 60s while the client kept a dead pane in its tab bar. Persistence still gates *removal* — publishing absence before the membership fence is durable would let a crash resurrect the surface. It no longer gates the proof: the observed exit is itself the attestation. The exit-first ordering already had a passing test; the renderer-first ordering had none, and that is the one users hit. Both orderings are now pinned, with exit-first as the control that makes the renderer-first failures mean something. Wire: `retiredTerminalSurfaces` is an existing optional field on an existing path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is Rule 1 — an old client that ignores it degrades to the two-inventory route it already uses today, so no capability gate is needed. The sentence "the host starts sending a frame it did not send before" reads like Rule 3; it is not, because the frame shape, the field, and the reader contract are all unchanged. * test(runtime): pin the removal frame retiring a still-live publisher KNOWN RED (`it.fails`), no product change. Found while verifying the close retraction fix: once the emptying actually reaches paired clients — a state the previous behaviour never allowed, because nothing propagated — re-adoption of a later create is flaky. Measured 1 failure in 6 runs of the two-client journey. `decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>` retraction as a publisher handover: it retires the still-live renderer epoch and installs the retraction as current, while the removal also clears the live freshness record. The next frame from that same running publisher then matches no lineage and reads as a retired generation, so it is outranked and the publisher is locked out of the worktree until its generation changes. `local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact scenario and has a revive escape; the mirror path has none. The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match, so a republication carrying `:headless-merge:` walks past the fence and only a bare same-epoch republication is locked out. Not fixed here on purpose. Dropping the retirement makes the red case pass but breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against delayed predecessor epochs`, which asserts a same-epoch higher-version frame after a removal must be rejected. At this layer those are the same frame — this function holds no `receivedFrame`, so it cannot separate a delayed predecessor from the live publisher speaking again. The fix belongs in `shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and currently defers to the same epoch fence. That is a contract change across two functions and an existing invariant, not a one-liner. * fix(runtime): a removal retraction is not a publisher handover The host drops a worktree's entry when its last tab closes and announces it with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a publication: `decideWebSessionTabsSnapshot` and `recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as current, which pushed the still-live renderer epoch onto `retired`. The removal also drops the live freshness record, so the next frame from that same running publisher matched no lineage, read as a retired generation, and was outranked. The live publisher was locked out of its own worktree until its generation changed. That is fail-closed, and it is why re-adoption after an emptying was flaky once the emptying actually reached paired clients. A retraction and the live publisher's next frame are the same epoch at a higher version, so epoch identity cannot separate them and never could. Delivery order can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the worktree's newest received evidence instead of deleting the ledger, so `shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply path passes before `decideWebSessionTabsSnapshot` — fences a frame that reserved its received frame before the retraction while admitting one that arrives after it. The boundary carries the retraction's own epoch, which never matches a host publication, so a later live frame may still restart its version counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the same conclusion for the local path: a retired epoch is not proof of a dead generation. `keeps a removed worktree fenced against delayed predecessor epochs` pinned the delayed predecessor at the raw decision layer, which is the same call as the live publisher's republication. It now pins the identical scenario — same epoch, higher version, still rejected — through the receive-and-apply path that actually holds the ordering, plus the composed gate as production spells it. The committed `it.fails` repro is not sufficient on its own: it records no received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement turns it green while the publisher stays locked out on every real path. A receive-and-apply case is added alongside it to close that gap. * test(runtime): pin the retraction boundary against a stale inventory omission Mutation testing left a survivor: writing the boundary unconditionally, instead of only when it advances the ledger, passed the whole runtime suite. It is not inert. A visibility-resume inventory reserves its received frame before it lists, so an omission it reports can be older than a stream frame that landed meanwhile; without the guard that stale omission rewinds the ledger, forgetting the stream frame's version, and a delayed list reserved in between is then readmitted instead of outranked. This pins that ordering. The one remaining survivor is the boundary's `snapshotVersion`, and it is inert: the ledger's version is read at exactly two sites, both reachable only when the incoming frame's epoch equals the stored one, and a retraction epoch never equals a live publication. * test(runtime): cover the fences the retraction change narrowed Two gaps found by mutating the fences themselves rather than the fix. Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed the entire runtime suite. It is not unreachable: a superseded generation whose sibling stream delivers its frame after the handover outranks the successor on delivery order, and only the retired-epoch check rejects it. Retractions used to exercise that fence too; now that they no longer retire anything, a genuine handover is the only thing left that reaches it, and nothing covered that. The fence is narrower than it was, not dead. The second case pins rate-independence. The defect surfaced 1 run in 6 because `hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage` treats `:headless-merge:` as the same publisher, so a merged republication walked past a fence a bare one hit. The removal path is now asserted over both epoch shapes through the full path, so a fix that only re-rated the defect instead of removing it would fail here. * fix(runtime): give "same publisher" one answer across the epoch fences Separable from the retraction fix beneath it, and it changes handover-path behaviour: a superseded generation that republishes under a merged epoch is now rejected where it was previously accepted. Take it independently or not at all. `publisher-identity-fences.ts` held two answers to "is this the same publisher". `noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base and retired the base when the merged form became current, while `sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are contradictory, and the retired-value check's exact-string match was the shim that kept them from ever meeting: a merged frame was a different string, so it never looked retired no matter what had been retired. The cost was that the same predecessor was accepted or rejected depending on which shape it arrived in. A generation a successor had replaced was fenced when it republished bare and admitted when it republished merged — the fail-open half of the same disagreement whose fail-closed half was the removal defect, and the reason that defect reproduced 1 run in 6 rather than every time. This cannot be fixed in the fence alone. Making the fence lineage-aware while a merged epoch still retires its base has the generation retire itself: the rebuild arrives, retires its own base, and the fence then rejects it as a retired generation. So both sides move together — a lineage sibling advances the current epoch instead of superseding it, and inherits its generation's retirement instead of escaping it. Scoped to the publication-epoch functions. Runtime-id retirement keeps exact matching, and `local-structured-session-tabs-sync` keeps its own `hasRetiredValue` call, where a lineage sibling is already excused explicitly and a retired epoch is deliberately not treated as proof of a dead generation. * test(e2e): journeys for a reopened client and two clients on one host Two gaps this suite had no coverage for, both driven end to end against a real paired desktop client rather than at a seam. A relaunched client holding a live remote terminal: every paired restart spec here restarts around a browser pane, none around the terminal the user is actually mid-work in. The host-side fixture's on-disk sink is the oracle — one READY for the whole run proves the host never re-spawned the session, and a recorded line for input sent after the relaunch proves the restored pane is wired to that same process rather than painted with its scrollback. Two clients on one host across an emptied workspace: the tombstone is client-local on the runtime path, so a client that never held a row still seeds into a workspace another client deliberately emptied. That asymmetry is by design; a client falling out of step with the host and staying there is not. Phase 0 is the control — without it a later divergence cannot be attributed to the emptying rather than to mirroring never having worked. The input probe goes through `pane.terminal.input`, not `window.api.pty.write`: a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a direct write is swallowed and the assertion passes on nothing. The pre-restart control exists to catch exactly that, and did. * test(e2e): keep the two-client journey spec type-clean * test(e2e): pin the close retraction a paired host does not publish * docs(e2e): say why the red close-retraction spec sits on this PR The spec was written on a branch carrying neither of this PR's publish-side fixes, and its own diagnosis -- the fault is the host's publish-after-close, not any client's mirror -- names exactly what they change. Landing it here makes CI the measurement rather than leaving a red spec parked on a branch with no fix in it. Records the one thing a reader needs to not do: skip-tagging it. And why the obvious split is not a block move -- phase 2 depends on phase 1b's emptying and both share the two-client pairing fixture, so splitting means duplicating the fixture. * test(e2e): the close-retraction spec is green on this branch, measured It was written to pin a defect and was red where it was written. On this branch, with `publish a terminal retirement proof on the exit's own evidence` and `a removal retraction is not a publisher handover` both present, it passes -- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a prior baseline of "none reached either client within 90 seconds". So the KNOWN RED header had become the thing it warned about: a test carrying prose asserting the very behaviour the commits beside it remove. Rewritten to record the measurement and the numbers to regress against, and to keep the one instruction that still applies -- if it reddens again, do not skip-tag it; the failure shape is a 90s timeout on both clients at once while creates still propagate. No assertion changed. Comment only. * test(wire): pair the session-tabs retirement proof across two builds The stack makes a host start sending a retirement proof on its own frame when no surface removal carries one. The change argues Rule 1; Rule 3's fourth bullet covers a frame the host starts sending on an existing path, so the claim is measured against v1.4.199 rather than accepted. Neither existing cross-version suite reaches session-tabs: the terminal one covers the binary stream, the agent-session one covers agentSession.*. Result: the old client acts on the proof-only frame, because the whole client half of this surface is unchanged. The old-host cells are pinned to a release that cannot publish the frame at all, which is what makes the new-host cells mean something. * fix(lint): clear the casting gate on the surface-lost inventory main tightened typescript/consistent-type-assertions to assertionStyle: never, which the rebase brings onto these added lines. The retraction read narrows on the property instead of casting; the fixture and cross-build-import casts carry per-site SAFETY rationales. * fix(lint): bind the protected-stamp cast to a name The leading-semicolon parenthesised call put the suppression on a line oxfmt then reflowed away from the assertion it covers. Naming the narrowed handle keeps the directive next to the cast. * fix(runtime): route every non-null surface write through the stamped writer `ptyHoldsRecordedSurface` trusts a record only while its stamp is current; after that the leaf map answers. Four writers still named a pane with a bare `tabId = / paneKey =` — orphan adoption (both branches), split, create on an adopted stable pane, and TUI-owner recovery — so a record that had already been contradicted stayed contradicted after the claim, and `terminal list` reported the just-claimed PTY `orphaned: true` until the renderer's next graph statement re-recorded it. Before this branch those sites read as attached at once, so this was a regression window of one round-trip, and `indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned". `recordPtySurface` is now the one writer; the adoption module reaches it through a port because it has no `graphSequence` of its own. The nulling writers are untouched: a null surface is never held, stamped or not. * test(runtime): keep one copy of each publisher-fence case The removed-frame suite asserted four properties that another case in the same suite or the lineage suite already pinned: - the decide-only readmit and the bare full-path readmit are the bare arm of the parameterized full-path readmit, verbatim; - the merged-suffix decide-only readmit is the merged arm of the same loop; - "still fences a predecessor a successor replaced" is the lineage suite's bare arm with different version numbers; - the recovery-gate handover case is the lineage suite's recovery-gate case with a bare late frame instead of a merged one, so that test now runs both shapes and this copy goes. Mutation-checked: reverting each of the five renderer changes on this branch (retire-on-removal in decide, noting a retraction current, the exact-match retired fence, merge-supersedes-base, dropping the ledger on removal) still fails at least one of the remaining ten cases. Also corrects the suite header: a retraction carries a synthetic `removed:` epoch, so it is the in-flight predecessor frame, not the retraction, that shares the live publisher's epoch and needs delivery order to be separated. * test(e2e): fail the two-client journey when phase 1a cannot run Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace that starts with one terminal skipped the control silently while 1b and 2 still ran, and the spec passed green without ever exercising the close-with-others-open retraction it was written to measure. The skip is now a recorded failure naming the host count. * fix(runtime): order every session-tabs apply path against the retraction A closed terminal came back on the other client because "this worktree was retracted" was neither durable nor universal: - `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in receipt order at all, so a list the host answered before the close applied after the retraction had already cleared the worktree. It is a production path for close, create, activation, split and PTY reconnect. - the boundary lived in a single receipt slot the next stream frame overwrote, and in a fence that only existed when a recovery happened to be pending when the retraction landed, so a pre-close list could out-rank the republication on `snapshotVersion` alone. Replace both with one raise-only removal watermark per (environment, worktree) and give the list path a receipt position, reserved by the request and carried in its answer so a dedupe joiner inherits it rather than minting a newer one. The pending-recovery fence and its bookkeeping are dead once the boundary is monotonic. The exact-match retirement check in the receipt ledger becomes the one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be noted as current and retire the live publisher out of its own worktree. On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence` at write time, so any `paneKey` write claimed the standing of a fresh graph statement. The inventory restore in `terminal list` therefore un-dropped the very pane the read was meant to report, on every listing. A surface claim now carries no graph standing unless its writer names one: the graph statement, live leaf output and spawn do, while the inventory restore, the floating liveness restore and the mobile projection replay do not. Defaulting this way means a writer that says nothing fails safe and self-corrects, which the type alone could not guarantee across the projection contract's own `recordPty`. Spawn claims now span the one graph statement the renderer may already have in flight, and retirement proofs compare by identity instead of by position, so a re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing. * fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher A worktree the host has published nothing for still answers a forced list, with a synthesized `none`/v0 frame that means "ask me later" (host-session-snapshot-authority.ts). Every post-close list and every activation of an emptied worktree gets one. Noting it as a publication retired the renderer generation that is still live, and because that epoch is per-process, the terminal the user created next never reached this client — the same lockout the retraction path was already careful to avoid, through a door it did not cover. `local-structured-session-tabs-sync` already skips the placeholder for this exact reason; the web mirror now does too, on both the receipt ledger and the frame decision. Bound the receipt ledgers by frame age rather than entry count. One bootstrap inventory records a receipt per worktree under a single reserved frame, so evicting by insertion order dropped that batch's own earlier entries, and an absent receipt is what the recovery gate reads as "no evidence for this worktree". Only a receipt no in-flight frame can still be ranked against is droppable. Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh path. Ordering is that path's gate, not an optional collaborator a caller's module mock may leave out, and being reachable only through the barrel is how the path came to have no ordering at all. * fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it `recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner binding — the same replayed-evidence class as the inventory restore — but stamped it with the current graph sequence, so a pane the renderer had already dropped read as attached for one more statement. The guard below it needs the tabId and paneKey, not the standing. Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does and does not cover: an unpublished-worktree placeholder is withheld from epoch noting only. It still applies, because rejecting it outright would drop the terminal reconciliation that legitimately rides on it. * fix(runtime): keep the retraction boundary out of the receipt bound Bounding the removal watermark alongside the receipt ledger reintroduced the defect the watermark exists to prevent: past 512 retracted worktrees, evicting a boundary readmits every pre-close frame it was fencing, and a delayed list resurrects the closed tab. A boundary is not a cache. One number per worktree ever retracted on an environment is the cheaper price, and environment teardown drains it; only the receipt ledger stays bounded, by frame age. Split the orphan-adoption port by provenance so the last writer that disagreed with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans` replays the persisted binding when the claim already matches it and writes a new one otherwise, and both went through a single `recordSurface` that stamped the current graph sequence — so re-adopting an already-adopted orphan lifted a dropped pane's stale stamp and reported it attached, in a quiet workspace possibly forever. The replay now names the pane without standing and the fresh claim takes spawn standing, like every other writer. Replace a receipt-count assertion that was vacuous for a map keyed by environment and worktree with the mirror state and freshness it was standing in for. * fix(runtime): keep a closed-tab worktree under the epoch already publishing it `closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its sibling headless writers carry the stored `publicationEpoch` forward and mint only when there is no snapshot to inherit from — because a write to a worktree is not a claim to publish it. The close was the one writer that claimed. A paired client retires the epoch a new publisher displaces, and the web mirror's retirement is final: there is no revive lane, and the per-worktree tracking teardown deliberately keeps the epoch history. So an ordinary close published a stranger for a worktree the renderer generation still owned, retired that generation on every client, and the renderer's next publication — carrying the epoch the close had just retired — was rejected forever. The user emptied a workspace, created a terminal, and it never arrived on either machine while `session.tabs.list` showed the host holding it. This is the same thesis the retraction path already states, through the door next to it: a retraction is not a handover, and neither is a close. Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each: phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s budget) and 0/6 after, with both clients adopting in single-digit milliseconds. * fix(lint): give the fixtures real types instead of casting past them The casting gate failed on eight assertions this branch added. All eight were suppressible, but the suppressions were not the problem: the casts were hiding fixtures that did not match the contracts they stood in for. `sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not a `TerminalTab` field and eight required ones were missing — and layouts holding only `ptyIdsByLeafId`. `as never` made both compile. They are now real `TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked against the type `listTerminals` actually reads. `terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for a field typed with *snapshot* tabs, which forced `as never` at the call and a cast on the snapshot itself. Production reads only `type`, `parentTabId`, `leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only fields were inert; dropping them lets the declared `RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab is now held by name rather than recovered from `snapshot.tabs[0]`. The remaining three casts are unchanged in kind and now carry correctly placed SAFETY rationales: reaching a protected member is the only way to drive these paths. `graphSequence` folds into the reach-through that was already there rather than opening a second one, and the map read narrows instead of asserting. Mutation-tested, all three suites, regression re-introduced for each: - epoch mint on close restored -> 1 failed | 1 passed - orphan check reverted to self-consistency -> 4 failed | 4 passed - placeholder retirement guard removed -> 1 failed | 7 passed src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed. `check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean. * fix(runtime): stop the headless placeholder graph from dropping every restored pane A headless server publishes one empty graph at launch so status clients see a ready server. It names no renderer pane and is never replaced, but it was counted as an authoritative graph statement all the same: `graphSequence` went 0 -> 1 while the leaf map stayed empty for the life of the process. Every surface claim written without standing - a persisted replay, an inventory restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the `>=` guard fails, the empty leaf map answers "no pane holds this", and the terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it, because the only graph that host will ever publish has already been published. On a headless or SSH host that is permanent, and it is the same lie #18191 is about, pointed the other way. The placeholder no longer spends a graph statement. A renderer graph still does, so a pane a real graph drops is still reported dropped - including on a desktop window promoted from headless, which the third case pins as a negative control. Mutation: restoring the unconditional bump fails the first two cases ("expected 1 to be +0", "expected true to be false"); the promoted-window control passes either way, as a control should. Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so adding it had switched the job's gate on, but the job runs an explicit file list that omitted it - the test executed nowhere in CI. It passes 8/8. |
||
|
|
002ff3ddb8 |
feat(mobile): per-host generation store for the mobile web bundle (OTA phase B, 2/4) (#21409)
* refactor(mobile): export the mobile web manifest read schema
The generation store re-parses the manifest it cached, and it must read it back
with the same loose reader the fetch accepted it under: parsing strictly after
accepting loosely would turn a host's added field into a forced redownload on
every launch.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): add the per-host mobile web generation store
Turns a verified bundle into an atomically activated, host-scoped generation
directory under the OS cache, and reads it back. No RPC, no UI, no flag: the
native view is later handed the directory read-only and never writes to it.
The single directory under `generations/` is the activation, so there is no
activation file to edit: a commit deletes every other generation before the
rename, an interrupted one leaves zero generations for the runbook's redownload
rule, and two directories or an unreadable manifest drop the host tree instead
of guessing. `tmp/` is never an activation candidate and every one of them goes
at launch. `hosts.json` carries recency only, so losing it costs eviction order
rather than a generation.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never evict the host a commit just activated
`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* Revert "fix(mobile): never evict the host a commit just activated"
This reverts commit
|
||
|
|
1e7a69710d |
feat(mobile): update wall for the desktop-served mobile web bundle (OTA phase B, 1/4) (#21411)
* feat(mobile): decide whether a web bundle may open against its host A pure verdict for the bundle update wall, ordered so the answer names the soonest cause: a host with no bundle has no manifest to disagree about, and an unknown manifest schema makes the protocol window inside it unreadable. Same `?? 0` defaults as `evaluateCompat`, so an absent status field reads as the oldest host that could have answered rather than as permission. Every blocked verdict is terminal. There is no native workspace fallback, so each one carries the numbers it compared for the support breadcrumb. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read an unknown bundle schemaVersion through to the wall The client reader pinned `schemaVersion` to the one schema this shell knows, so a future schema 2 failed the parse before `evaluateMobileWebBundleCompat` could call the shell too old. The user would have seen a transport error where the update wall belongs. The host's own manifest stays closed in both directions, where it is written. Goldens are unaffected: every recorded reply carries schema 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the block screen copy for the bundle walls One component still renders every wall. `updateSide` picks the app to update from the reason, so the copy and the store link cannot disagree, and a new reason is a compile error there rather than a mobile title over a desktop button. The existing protocol copy is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the bundle protocol window the update wall compares The loose reader left `runtimeProtocolVersion` and `minCompatibleRuntimeProtocolVersion` as unknown index members, so the parsed manifest could not reach `evaluateMobileWebBundleCompat` without a cast. Both are now read as non-negative ints, and the reply-schema test pins it at the call site: the wall is invoked on a parsed manifest, so dropping either field stops compiling. A host that omits the window is now refused. Only a host too old to advertise `mobileWeb.bundle.v1` can send one, and the phone never asks such a host for a manifest. The probe test's fake manifest gained the fields it was missing, which is the typed reader catching its first stale fixture. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): say whether a bundle verdict actually checked a manifest `ok` meant two different things: the manifest was read and its window contains the host, or no manifest had been read at all. A caller that mounted on the second would mount an unchecked bundle, so `manifestChecked` separates permission to fetch from permission to open. The host-status input is now a `Pick` of `HostStatusReply` instead of a hand-copied pair. Both fields default through `?? 0`, so an upstream rename would have silently blocked every host rather than failing a build. Drops two assertions that restated the module's own literal back at it. What proves today's bundle opens is that the shared contract's schema version is a member of the supported list, so that is the assertion left standing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): offer a refetch, not a store, for a bundle the host outgrew `bundle-incompatible` on the mobile side means the workspace cached for this host is older than the host's client floor. A store update cannot clear that and a reconnect can, so the screen no longer sends the user to a download that would change nothing. The button is gone rather than relabelled, because the recovery is leaving this screen, and the note drops its "already updated?" opener for the same reason. `blockRemedy` replaces `updateSide` and is now passed to the copy instead of recomputed there, so the title, the body, and the button are decided once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the platform assertion from the block-screen mock The mocked `Platform.OS` was widened with an assertion so a test could switch stores. An annotation on the binding does the same widening in a position the compiler checks, which is what the changed-code quality gate asks for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which bundle-compat default is fail-open, and drop a dead field Both comments claimed the two host-status defaults point the same way. Only `protocolVersion` is absent-means-oldest. An absent `minCompatibleMobileVersion` is `?? 0`, which is no floor at all, so the mobile arm is fail-open by design and matches `evaluateCompat`. A reader taking the old sentence at face value would have gone looking for a bug. `supportedSchemaVersions` had no consumer on the verdict: the block screen renders a title and body, and B4 reads neither. The exported constant stays, since that is what the wall is decided against. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9d1826ae65 |
fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057)
* fix(session): keep a renamed worktree's rows from matching on the id it lost Three persisted session fields survived a worktree re-key still naming the old identity. Two of them are suppression records, so a stale id does not read as residue -- it silently re-admits state the user removed: - closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host tab when the tombstone's worktree equals the tab's, and no snapshot ever covers the old id, so the tombstone never retires either. - clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's worktree, and an unresolvable selector answers selector_not_found -- which the replay reads as definitively gone and uses to DROP the intent. - clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against the row's own workspaceId, so both halves have to move or the pages are never rehydrated. Fixed on both sides of the rename: the main-process persisted migration and the renderer's live store, which would otherwise write the stale values straight back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND, the census these three fell out of, with the shipping owner collector as its oracle. * docs(session): record why a re-key clobbering an existing target stays unfixed Not a missing guard -- an unresolvable one. Keeping the target is correct when it holds a real closed-last-terminal tombstone; keeping the source is correct when the target row is a stub; nothing records which is newer. The recency map is the only one that can settle it, because Math.max needs no such ordering. * test(persistence): measure the downgrade direction for worktree identity The stack widens migrateWorktreeIdentity to repoint worktreeId inside session rows. That changes what lands on disk with no wire change, which is Rule 3's shape applied to persistence, so it is measured against v1.4.199 rather than reasoned about. Result: new-build state does not break the old build. The old build renames over it without throwing and loses no row; the two row kinds it cannot repoint stay stale, which is exactly what its own renames already produce. The numbers are measured. A first draft asserted the old build repointed no inner rows at all; it repoints two of four, and the probe is what caught that. * test(ci): run the worktree-identity downgrade lane instead of describing it The cross-version job names its files explicitly, so a new one is inert until it is listed; the sharded unit job excludes the whole directory and the E2E router only takes `*.spec.ts`. Also pairs the forward-compat case against the current build — the stack's own field-list walk is the guarantee that matters, and only the frozen build was exercised. * refactor(session): drop the type assertions the rename migration leaned on `consistent-type-assertions` landed on main after this branch last built, and three of the `as never` fixtures were hiding real contract drift: a browser workspace row missing six required fields, a tab group naming three fields the type does not have while omitting the two it requires, and a sleeping-agent row whose `providerSession` had neither `key` nor `id` and whose `state` was not in `AgentStatusState`. Indexing the session by a computed field name is what forced the casts in the migration, so the four row maps are now spelled out; the census test is what keeps a fifth from joining silently. The renderer test builds its state from the real slice instead of casting a four-field partial. * refactor(test): name the module namespace the skew harness reads `object` is too broad for the anti-slop gate, and the import helper already declares what it hands back. * docs(test): say which maps the harness actually supplies The two under test live in slices this harness does not mount, so calling it "the real slice's state" overclaimed. |
||
|
|
d139760c06 |
fix(sessions): cancel transcript acquisition during host teardown (#21006)
* fix(sessions): cancel TUI transcript acquisition during teardown * fix(sessions): settle canceled handoffs without replacement launches * test(sessions): assert fenced teardown release --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
01a33bc427 |
fix(git): respect existing .orca ignore rules
Respects effective local, WSL, linked-worktree, runtime, and SSH Git ignore rules before updating .gitignore. Fixes #21212. |
||
|
|
c263f5d092 |
chore(mobile): repin the RPC recording baseline to main after #21374 (#21402)
#21374 squashed to |
||
|
|
1ff4fe677c |
fix(main,preload): tear down renderer relay and preload listeners (#20909)
* Clean up renderer relay listeners on teardown * fix(main): guard empty markdown relay results * test: document relay window test double safety * fix(relay): retain web contents through window destruction --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
f819ed96ca |
fix(skills): keep the disposal verdict when staging cleanup fails, and retry release-cut installs (#21366)
* fix(skills): keep the disposal verdict when staging cleanup fails `begin()` ended with `await this.removeOwnershipIfDisposed()` inside its `finally`, so when a caller raced `dispose()` the rejection it received was whatever that opportunistic `rmdir` threw -- not `skill-upload-service-disposed`. A caller could not tell "the service shut down" from "the filesystem broke", and the Windows release gate saw it as `EPERM: operation not permitted, rmdir`. Two causes, both fixed here: - The EPERM itself: an in-flight operation and disposal each call `ownership.remove()`, so two `rm -rf` run concurrently against the same owner directory. On POSIX the loser reads ENOENT and `force: true` swallows it; on Windows the loser reads a delete-pending directory and gets EPERM. `SkillUploadStagingOwnership.remove()` now joins one removal and forgets it on failure so a later caller still retries. - The masking: cleanup in a `finally` no longer replaces the outcome of the call it is cleaning up after. Disposal retries staging removal and reports its own failure, matching `removeUnpublished`/`retainFailedCleanup` in this class. Both regressions are pinned platform-independently: one injects a failing ownership removal and asserts the racing `begin` still rejects with `skill-upload-service-disposed` while `dispose()` reports the cleanup failure; the other models Windows delete-pending rmdir in the `node:fs/promises` mock, which turns a second removal into EPERM on every platform. * ci(release-cut): retry the installs that fetch node-gyp headers `golden e2e windows` installs with lifecycle scripts enabled, so pnpm runs node-gyp for the `native/windows-registry` workspace project, which downloads that Node version's headers from nodejs.org. A single `read ECONNRESET` on that fetch failed a blocking release gate, and the release build job one screen below already wraps its install in `nick-fields/retry@v4` for exactly this class of failure. Both remaining unretried installs in this workflow (the blocking platform golden and the non-blocking rendering-evidence lane) now use the same wrapper, and a contract test keeps every release-cut install retryable. |
||
|
|
1e3795de99 |
fix(log-tail): retire watches with their renderer lifetime (#21009)
* fix(log-tail): retire watches with their renderer lifetime * fix(ci): clean up renderer tests --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
2bdf281433 |
fix: avoid retaining foreign SSH file frames before metadata (#21167)
* fix: avoid retaining foreign SSH file frames before metadata * test(ssh): exercise empty metadata through the streaming mux fixture * fix(ssh): fail the file read when beforeResolve never runs Moving the metadata install from .then() to beforeResolve moved it from a mandatory callback to an optional one, and handleResponse clears the request timer before beforeResolve runs. That left "response fulfilled, metadata never installed" with no deadline: the read never settled, holding its notification and dispose closures until mux disposal. Before this PR the same state failed after the 60s inactivity deadline. Unreachable with the concrete mux, which calls resolve on the line after beforeResolve, but the hook is optional in the type and nothing enforces the pairing. The guard is a no-op on every real path: empty, missing streamId, cap-exceeded and alloc-failure all settle first, and the success path sets metadataReady. Found during review of #21167; raised at https://github.com/stablyai/orca/pull/21167#issuecomment-5726058832 --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
06a8ca5f69 |
fix(runtime): keep a client-dirty mirrored file dirty across a host republish (#21393)
The host publishes only its own store's isDirty and never learns about client edits, so rebuilding a mirrored OpenFile from the snapshot cleared the client's flag while editorDrafts still held the draft. The tab strip then closed the tab with no unsaved-changes prompt and closeFile deleted the draft; the external-change reload guards would reload over it too. Keep the client's flag when the client's file is dirty and it holds a draft; with no draft the host's flag still wins so a host-side save does not strand the tab as dirty. A host-side save never clears a client draft. Fixes #21392 |
||
|
|
0d7381d1f2 |
fix(terminal): keep an unverifiable park-reveal snapshot apart from an empty pane (#21396)
On a park-reveal of a remote-runtime pty the host snapshot probe is the only
structural paint (the reattach carries no relay tail). Every non-snapshot
answer collapsed to null with no retry, so a host that stayed silent past the
request timeout, or answered 'no-serializable-buffer' ("not proof the pane is
empty"), painted the same blank pane as a host with nothing. That reads
unverifiable as exited (docs/reference/ssh-execution-boundary.md).
Classify the probe three ways: a host image paints; permanently-unavailable /
unavailable paints nothing and asks nothing; everything that proves nothing
(timeout, host declined for now, local lane gate, imageless success) paints
nothing and hands off to the hidden-output restore loop, which already budgets
retry-worthy answers (7 host declines / 30 local gates / 5 re-arm cycles),
repaints from the host on success, and ends in the explicit loss banner. The
reveal's own probe is charged to that same budget, so the bound is shared, not
doubled. No structural clear is issued on the unverifiable path, so whatever
the layout replay painted from the client's own copy stays visible.
|
||
|
|
69246e9b06 |
fix(terminal): retire explicitly closed pending split connections (#21001)
* fix(terminal): retire explicitly closed pending split connections * test(memory): keep pending split proof compatible with formatted source * fix(terminal): confirm pending split retirement before stopping work * fix(terminal): restore the pending split-close gates CI checks Three CI gates were red on this branch and all three were this branch's own. The hook-order parity snapshot did not count the `confirmedCloseRef` this branch adds to `use-terminal-pane-close-actions.ts`. Dumping the flattened order against clean `main` shows exactly one added `useRef` at position 148 and no reordering, so the count moves 211 -> 212 and the digest with it. `pending-split-close-test-fixture.ts` is Vitest support code, but it sits outside the `*.test` / `*.spec` / `tests` globs that already switch `anti-slop/no-module-mocking` off, so the gate failed on all twelve of its `vi.mock` calls. It carries a file-scoped disable with the reason, matching `work-item-search-test-harness.ts`. `fix.patch` still described the pre-confirmation shape of the close hook, so `reproduce.mjs` aborted with `Source changed` and the cited ablation could not run at this head. Regenerated against the committed sources; the harness again reports 10 pass / 14 fail before and 24 pass / 0 fail after. Merges `main` rather than rebasing: #21005 is stacked on this branch. --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
60a774c30c |
feat(mobile): client operations and dev probe for the desktop-served mobile web bundle (OTA phase A, 5/5) (#21374)
* chore(rpc-contract): provisional catalog entries for the mobile web bundle methods PROVISIONAL, and the only commit on this branch that must not survive the merge as written. `rpc-params-catalog.generated.ts` is generated from the host method registry, and A5's client operations cannot name `mobileWeb.bundle.manifest` or `mobileWeb.bundle.chunk` until A3 registers them: `defineRpcOperation` constrains `method` to `RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD`. These two entries are what the generator emits once A3 lands. After merging A3, run `pnpm run generate:rpc-params-catalog` and keep its output, not this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): checked client operations for the desktop-served mobile web bundle Two `defineRpcOperation` descriptors over the A1 contract, both `require-result-or-throw` at `on-settle`: there is no partial success in a bundle read, and a salvage policy would produce a half-bundle that fails a hash check far from the cause. Readers are hoisted `looseObject`s that require only what this client reads, so a later optional member stays a Rule 1 addition for released phones; the host's own schemas stay strict. `dataBase64` is bounded by the contract's chunk size, so a host that overshoots is refused at the boundary rather than at reassembly. `readMobileWebBundleErrorCode` maps the host's six codes out of the thrown `code: message` diagnostic and answers null for everything else. Membership comes from the contract's own enum, which is built from its `hostUnionArms` record, so the arms here cannot drift from the host's union. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): fetch and verify a whole mobile web bundle over the paired connection `fetchMobileWebBundle` reads the manifest, pages every asset at the chunk size the host advertised, and verifies each reassembled asset against the manifest's sha256 before returning it. Nothing is cached and nothing is rendered: this is Phase A's proof that the pipe carries a bundle intact. Four asset reads run at once and no more, because the host refuses the fifth concurrent read on one connection with `mobile_web_bundle_read_limited`; paging inside an asset stays sequential, since the next offset is only known to be wanted once a reply says it is not the last. Every chunk reply restates its build, path and offset and the whole asset's length and hash, and all five are checked. A desktop that auto-updates mid-download answers a later chunk from a different build, and nothing else in the reply says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): dev-only troubleshooting row that fetches the mobile web bundle The Phase A proof that the pipe works on a device. Tapping it fetches the whole bundle from the paired desktop and reports the build, asset count, byte count and elapsed time, or the host's error code. `TroubleshootView` gains a `developerRow` slot and the route fills it only when `__DEV__` is true, so a shipped build mounts nothing: no host lookup, no client acquisition, no request. The row reuses the screen's existing button and check-row styles, so it adds no visual vocabulary. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): recording scenarios for the mobile web bundle operations Two families over the real product modules: `mobileWeb.bundle-manifest` drives the manifest descriptor alone, so the loose reader's verdict on one reply is the whole observation, and `mobileWeb.bundle-fetch` drives the paging flow over a two-asset bundle whose entrypoint spans two chunks. The fetch family's state carries the decoded bytes of every asset rather than a count. A reassembly that misplaces a chunk still has the right length, so only the bytes say so. Goldens land with the repin in the next commit: the recorder fences on the pinned tree, and these modules are not in it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording corpus and record the mobile web bundle goldens `--record` refuses on any tree but the pinned one, and the pin predates this branch's product modules, so the corpus is repinned to `bbf8264425` — the last commit here to touch a fenced path — and re-recorded whole, the way `rpc-recording/README.md` prescribes for a product change. The delta is the clean one that repin predicts. All 778 existing goldens move exactly one line, `baseline`, and nothing else: no body moved, no other header key moved, none was deleted. Nine are added, two pilot per family plus the five reply matrices the two families derive. The fetch adapter projects its result rather than returning it whole. The result carries a Map of Uint8Arrays, the observation refuses a non-plain object, and the first recording lost the settlement and filed an unhandled rejection in its place. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the fake host's params through a boxed field read The changed-code casting gate refuses the assertion the fake transport used to type its recorded params. Boxing the value the way `settings-read-operations.ts` does reads the same fields with no assertion, and a non-object params reads as absent instead of throwing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to this branch's last fenced commit The casting fix landed under `mobile/src`, which is a fenced path, so the pin no longer named the tree `--record` runs on. Repinned to `79c3eed6db` and re-recorded. Every golden moves the `baseline` header and nothing else, which is what a repin with no product change is: the edited file is a test, and no recording loads one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mutation evidence that the fetch projection observes the bytes Writes every chunk at offset 0, so a multi-chunk asset reassembles as its last chunk over a zero-filled buffer. The length still matches the manifest, so only the sha256 check and the decoded bytes in the projection can see it, which is what the fetch family's state exists to show. The mutant is killed. `mutants/` is outside every golden digest, so this moves no recording. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every worker's chunk reads the moment one asset fails `stopped` was read only between assets, so the other three workers paged their asset to the end after the fetch had already rejected: 121 chunk requests where 4 had been issued at the rejection. Each one holds one of the host's four read slots, so an immediate retry was refused with `mobile_web_bundle_read_limited` that only the abandoned workers caused. An internal AbortController now stands beside the caller's signal and is checked before every chunk request, not just between assets. Also pins the entry abort check, the overrun check with real bytes, the measured byte total, and a schema refusal whose message is prose rather than one of the six codes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the code anchor and both operation descriptors `RPC mobile_web_bundle_unavailable failed` separates the anchored reader from an unanchored one; the prose test that claimed to cover it had its first token at index 0, so the anchor was load-bearing and untested. Also pins that a schema refusal, which the dispatcher raises with zod prose before the bundle handler runs, reads as no code, and that both descriptors stay `require-result-or-throw` / `on-settle`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): dial the host on tap in the dev bundle row, and name it Opening Troubleshoot in a dev build acquired a client at mount, which is what kicks a dial, on a screen that opened no connection before. The probe now acquires only once the row is tapped, and each request owns its AbortController so a re-run, an unmount or StrictMode's second mount abandons the previous fetch and stops its chunk reads instead of holding the host's read slots. The screen carries no host parameter and troubleshoots every paired host, so there is no host it is "on": the row still takes the first paired host but now names it in the result instead of implying it speaks for all of them. The label says whether it is still connecting or already fetching. There is no `__DEV__`-conditional `require` idiom in this repo to trim the row out of a release bundle with, which the route now records. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): refresh the recorder corpus counts 397 scenarios, 787 goldens, 790 tests from the README's own three-file command. The 44 salvage goldens are unchanged; only the total they are quoted against moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin and re-record for the mid-asset stop Baseline moves to |
||
|
|
9907117569 |
feat(native-chat): record an explicit provider outcome on every structured turn (#21278)
A structured turn that FAILED was recorded as `completed`, identically to one that succeeded, so nothing downstream could tell them apart. Claude mapped only its two abort reasons to `interrupted` and let an API error fall through to `completed`; Codex collapsed every non-`completed` status to `interrupted` and read a missing status as a clean finish. Add `outcome` — success / failure / cancellation — to the turn record, emitted by both providers. The four-arm lifecycle union is deliberately untouched: it stays a report on what the HOST observed, and its readers are unaffected by construction. Absent means UNKNOWN and never success. Historical rows, older hosts, and any end the host inferred rather than heard (the child going away, a turn superseded before its result) all carry no outcome, so a newer client cannot mistake an old host's `completed` API error for a clean turn. Claude's abort-reason list had a second copy in the provider-fallback reader; both now classify through one `claudeResultOutcome`, so the durable verdict and the visible error row cannot drift. |
||
|
|
f9d5b6bb02 |
fix(renderer): dispose global listeners during HMR (#20908)
* fix(renderer): dispose combined diff cache listener on HMR * fix(renderer): dispose contextual tour key guard on HMR * fix(renderer): dispose activity pagehide listener on HMR * fix(renderer): dispose keyboard layout hooks on HMR * fix(renderer): dispose input quiet listeners on HMR * fix(renderer): dispose desync sentinel listener on HMR * 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> |
||
|
|
2cc34de756 |
fix(editor): keep an unresolvable mirrored file tab open with a truthful terminal state (#21375)
* fix(editor): keep an unresolvable mirrored file tab open with a truthful terminal state A host-mirrored file whose read keeps answering `selector_not_found` used to sit on the raw code forever (and, in the reverted #21363, was closed outright, discarding drafts). `selector_not_found` is the host's "could not resolve right now", not proof the workspace is gone, and the file-read path has no definitive absence code. Bound the retries as before, then swap in a truthful terminal message with Retry and Close tab. The tab is never closed automatically; Close routes through the unsaved-changes queue so a dirty draft is confirmed. Fixes #21041 * fix(editor): classify selector_not_found by RPC code, not message text Preserve `RuntimeRpcCallError.code` on `FileContent.loadErrorCode` and gate the host-unresolved terminal transition with `hasRuntimeRpcErrorCode`, so a host that sends `{ code: 'selector_not_found', message: 'Selector not found' }` reaches the same truthful state as one that puts the bare token on the message. Also drop the Close action on inline conflict-review rows, which are not open tabs and would have been a dead control. * fix(editor): localize the host-unresolved copy by sentinel, and pin the token matcher Separate the terminal state's comparison key from its display text: the retry hook stores `WORKTREE_HOST_UNRESOLVED_CODE` on `loadErrorCode`, and the error view localizes by that code (`editor.fileLoad.hostUnresolved`), so translating the message can never break the terminal check. Export the selector_not_found matcher and cover near misses (case, suffix, prose, wrong code) so only the defined token classifies. * test(editor): name the it.each parameter for the host answer it labels * fix(editor): drop the load-error Close action; closing stays with the tab strip The Close button routed through `requestEditorFileClose`, which skips the pinned-tab and shared-reference checks the tab strip applies, has no listener outside the Terminal workbench (floating editor panels), and on the conflict-review overview could target an unrelated open tab whose id is the same absolute path as a synthesized inline row. Rather than reimplement the tab strip's close semantics in a second place, the error view keeps Retry and its copy points the user at closing the tab. * fix(editor): reword the host-unresolved copy and namespace its sentinel The copy no longer points at a Close control that is gone ("close this tab from the tab strip") and no longer claims a scan is in progress, since `selector_not_found` is also thrown synchronously for unregistered folder workspaces and removed repos. The sentinel becomes `editor_host_workspace_unresolved` so it cannot be confused with the CLI's `worktree_host_unresolved` client error. The doc comment narrows the "no definitive absence code" claim to git worktrees and names the two host codes that are definitive but not yet classified. Refs #21041 |
||
|
|
1fa6fac17c |
fix(daemon): answer the per-pty snapshot predicate for the pty it was asked about (#21381)
canProvideAuthoritativeBufferSnapshot is contracted as "whether this exact PTY can return a sequence-safe provider snapshot" (pty-provider-contract.ts), and two of the three layers already route it per id: DaemonPtyRouter forwards to adapterFor(id), and DegradedDaemonPtyProvider forwards to the provider that owns the session. The daemon adapter was the leaf that discarded the id and returned supportsAuthoritativeBufferSnapshots — a negotiated protocol version, which is a fact about the connection, not about a pty. That is reachable, not theoretical. getProviderForPty falls back to the local provider for any id it cannot place, so a remote-runtime id (whose pty lives on another machine) resolves to the local daemon adapter, and pty:getAuthoritativeBufferSnapshotCapabilities answered `true` for a session this daemon has never owned. The renderer caches that as a definitive per-pty verdict, and because the leaf discarded the id it could not tell it had been asked about something it does not own. Today the wrong answer is masked: allowOrdinaryParkRestore short-circuits remote and SSH ptys before the cached verdict is read, so nothing consults it. This closes the gap before something relies on it — a caller reaching for a per-pty answer should not be handed a confident one that is wrong. Not touching that short-circuit. It is deliberate: SSH bytes transit the client's own main process into its headless mirror, so those panes have a local copy the predicate says nothing about, and the direct-SSH lane was confirmed to repaint from a daemon-backed restore with the park capture disabled entirely. Routing SSH around a daemon-snapshot predicate is correct, and removing the short-circuit would disable SSH parking for no correctness gain. The existing protocol-compatibility test asserted `true` for a made-up session id, which encoded the bug. It now spawns a real session, so it still proves the protocol-version gate without depending on an unowned id reading as supported. |
||
|
|
85576b6361 |
chore(mobile): bump to 0.0.51 and Android versionCode 18 (#21382)
0.0.50 is closed on the App Store and shipped as mobile-android-v0.0.50 with versionCode 17, so both values are consumed. Fastlane fails the iOS release when the resolved version is not higher than the closed train. |
||
|
|
4e3170a76e |
fix(accounts): free the account queue when a sign-in is abandoned, and show the Codex sign-in link (#21372)
* fix(accounts): free the account queue when a sign-in is abandoned Closing Settings mid sign-in left the `codex login` / `claude auth login` child running, and every account mutation shares one FIFO queue, so the next Add Account sat behind it for the login's whole deadline and then inherited the abandoned call's timeout toast. Cancel the pending login before enqueueing the next add or reauth (never inside the queue the abandoned login owns), give Codex the cancel handle and Cancel button Claude already had, and stop reporting a cancellation as a failure. Also surface the sign-in link Codex prints, with copy and open, so the flow can be finished in a private window or another browser profile. * test(accounts): drop the bare casts CI's changed-code gate rejects The service doubles still need a cast; one documented helper per file carries the SAFETY rationale instead of nine bare `as never`s. * fix(codex): a cancel must not discard a sign-in that already succeeded The Windows post-auth watcher gives a lingering codex login five seconds to exit after it writes auth.json. A cancel arriving in that window rejected the login, and the caller's rollback then deleted the managed home that had just authenticated. Refuse the cancel once new credential bytes exist: there is nothing left to cancel, and the close handler already treats that state as success. Found by review of #21372. * fix(codex): keep a refused cancel cancellable, and require the sign-in notice Review of the auth-aware cancel guard found two holes it opened: - The outer handle latched `cancelled` before asking the session, so a refusal killed cancellation for the rest of the deadline. On a host with no post-auth watcher that reinstated the very stall this PR removes. Latch only when the cancel is accepted. - WSL never reads a pre-spawn baseline, so the guard read the auth.json that was already there and refused from the first click, making a WSL reauthentication uncancellable. Require a baseline before refusing. Also from review: publish the sign-in link from a stdout-only buffer, so an interleaved stderr chunk cannot truncate it; require codex's own "navigate to this URL" notice rather than offering the first link in the output; hide the notice in a remote account scope, where it would name a login running on this desktop; and share the cancellation message instead of matching a duplicated literal. The Claude case joins the login-process suite that already owns the two neighbouring cancel cases, and the auth-snapshot helpers move out of the session file, which the additions pushed over the line cap. * refactor(codex): cut the sign-in-link plumbing to its smallest form Review found the change correct but larger than it needs to be: - The pending-link store was a class with one permanent subscriber, a never-called unsubscribe and a try/catch that could not fire. It is a field and a listener set on the service, beside the cancel handle it already owned — and the service now clears both in one place. - The optional login-session dependencies were always supplied. - The parser's https check could not fail; the pattern already fixed the scheme. The renderer's unmount guard inside a synchronous IPC listener could not fire either. - The broadcast channel and the cancellation message are single sources of truth in src/shared now, rather than exported next to a hardcoded copy of themselves. - The duplicated seven-line rationale in both services says the same thing in three, including why only add and reauthenticate supersede. - The codex suite reuses its own factory, and unmocks once. Also reverts four reformat hunks the formatter pulled in around edits. * fix(accounts): free the queue for a switch, not only for another add Switching or removing an account shares the mutation queue an abandoned sign-in was holding, so the commonest thing a user does after giving up — pick a different account — still spun for the whole deadline while Add recovered instantly. Both now supersede, as does the Claude side. Every caller is a person: the two IPC handlers and the mobile RPC methods. No poll, sync or CLI path reaches them, and a sign-in that already wrote credentials refuses the cancel, so a switch cannot discard one that succeeded. Also from review: the Cancel button regains the gap its Claude twin has (layout is allowed by the design-system rule; only the colour override was not), and the URL subscription says what it is — registration for the process's lifetime, with no teardown to hand back. |
||
|
|
71f3bdb700 |
chore(mobile): bump Android versionCode to 17 for the 0.0.50 release (#21335)
versionCode 16 already shipped as mobile-android-v0.0.48, and Android refuses an install whose versionCode is not higher than the installed one. Keep expo.version at 0.0.50 so the release tag can match it. |
||
|
|
b90837ee46 |
feat(mobile-web-bundle): advertise the bundle capability where a bundle ships (OTA phase A, 4/5) (#21376)
* feat(mobile-web-bundle): advertise the bundle capability where one ships status.get pushes mobileWeb.bundle.v1 only when the install's bundle resolves and its manifest parses, beside the other conditional capabilities. Dev trees and `orca serve` installs may carry no out/mobile-web, and a static entry there would promise a download that only ever answers mobile_web_bundle_unavailable. No protocol version bump: protocol-version.ts asks for one when a method or a required field is removed or changes meaning, not when a capability is added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that mobileWeb.bundle.v1 is inert on a released client Derives the old desktop's reply by removing the one capability from what the new one sends, rather than writing down what the old client had, and asserts every released read of status.get lands identically apart from that string: the gate hook, the three transport readers, the quick-command predicate and the worktree-create support probe. Proved red against three mutants: a closed enum on the capability schema (the salvaged field drops whole, so nothing publishes), a client-side filter over the new name, and a gate that changes floatingWorkspaceEnabled when it sees it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): name the invariant behind the fake client's cast The changed-code casting gate wants the rationale on the line, and the reason is narrow enough to state: every reader under test reaches the client through an rpc operation's `request`, which uses sendRequest alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7c7310fc43 |
Keep workspace reveals minimal for folders (#21373)
* Keep folder reveals minimal and require filter adjustment * Clarify minimal reveal test names * Resolve remote folder hosts during reveal |
||
|
|
b7d694ff7e |
feat(composer): choose a base ref in the New Workspace composer (#17250)
* refactor(repo): share the create-from picker outside automations Move CreateFromPicker and its test from components/automations to components/repo, next to the repo-scoped shared UI that already lives there (RepoCombobox, RepoBadgeLabel, repo-icon). The New Workspace composer will consume this picker instead of growing a second base-ref combobox. Pure move: no behavior change. The translate() keys are call-site literals, so no locale catalog is affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(composer): separate the branch that names a workspace from its base baseBranch carried two meanings at once. It is the ref a worktree is created from, and it is also what buildWorkspaceSourceSelection turns into the name field's branch pill whenever no work item is linked. Any second control that set a base therefore took the name field over: the pill replaced the text input, hiding whatever the user had typed. The name survived in state, and Advanced still exposed it, but the main field silently stopped showing it. Add baseBranchNamesWorkspace, true only when a branch was picked to name the workspace. The pill reads that flag; creation keeps reading baseBranch. Two call sites set it, because those are the only paths that make baseBranch defined with nothing linked — and an undefined base yields no pill anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(composer): let the New Workspace composer pick its base ref The name field's tabs pick how a workspace is named; the base ref is a separate decision the composer never exposed. Naming a workspace from a Jira, Linear, GitHub or GitLab issue therefore pinned the project's default base with no way to start from a release or a long-lived feature branch. Nothing below the UI was missing. baseBranch already crosses IPC next to linkedWorkItem and wins over every default in main, and the composer already computed handleBaseBranchChange and startFromResetHint — the card simply never declared those props, so its {...props} spread dropped them. Declare them and render the shared create-from picker under the name field. ComposerBaseRefPicker owns its own store reads, the way the sibling ComposerParentWorktreePicker already does, so the name section stays presentational and nothing subscribes to the worktree list while the picker is hidden. The picker is offered for a plain typed name and for issue-shaped sources. It is hidden where a base already exists: PR/MR sources pin the pull request's own head, a branch pick IS the base — and offering one there would silently turn a checkout of that branch into a new branch off something else, since picking a base clears reuse — and folder workspaces have no branches. It always opens on the project default: no sticky base. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(repo): drop a stale react-doctor suppression on the create-from picker no-adjust-state-on-prop-change no longer fires on this file: removing the directive and running the react-doctor pass over the directory — where the JS plugin actually loads — reports nothing, at the new path and at the old one on main alike. The suppression was already dead; the rename only put the file in the changed set, where the quality gate reports unused directives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(repo): list branches as soon as the create-from picker opens The picker only searched once two characters were typed, so opening it showed just the project default and whatever branches already had a worktree. The composer's Branch tab lists on an empty query through the same runtime helper; match it, and the picker offers the repo's branches straight away. Search stays debounced at 200ms and capped at 30 results, and it still runs on the repo's own execution host, so a remote repo lists its own branches. The Automations picker shares this component and gains the same listing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(composer): carry the base-ref naming intent through a saved draft `baseBranchNamesWorkspace` lived only in component state, so restoring a persisted draft always reset it to true. A base ref chosen in the picker came back as a name-field source pill, hiding the name the user had typed — the exact regression the flag exists to prevent, reappearing across a draft round trip. Persist it next to `baseBranch` and restore it through `resolveDraftBaseBranchNamesWorkspace`. A draft written before the flag existed records no intent and restores as a branch pick, which is the behavior it had when it was saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(composer): preserve independent base and branch name choices * fix(composer): pass naming-intent through the create-more reset test IssueSourceActions now requires baseBranchNamesWorkspace. The create-more reset fixture is a source-owned base, so the flag stays true and the next create still clears it. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
8d2f16856f |
fix(session): scope agent resume to the host that captured the session (#21288)
* fix(session): scope agent resume to the host that captured the session A provider session id names a transcript in one machine's agent state directory. Nothing in the resume path compared that machine against the one the resume executes on, so a record captured on host A reached a `--resume` run on host B, which answers `No conversation found with session ID`. Three things make the drift reachable: `worktreeId` is `repoId::path` with no host component, sleeping records are `'sleepingAgentKeyed'` so boot-time host-contention parking never arbitrates them and every partition merges into one map without retaining provenance, and both issuers resolve their launch target from the current catalog. Both issuers are gated. The activation sweep hands `quit`/`live` records whose pane still exists to the pane's own cold restore, so gating the sweep alone changed nothing in the SSH lane. Declines rather than guesses: the record is preserved and remains resumable by hand. A refused resume is recoverable, a forked transcript is not. The predicate fails open on anything it cannot positively rule out -- an unstamped record, an empty stamp, or a `runtime:` host, which a paired client uses to relabel its host's own SSH workspaces. The cold-restore gate consults both the pane's transport and the catalog. The transport alone was racy: it is unresolved on an early reattach frame, and that frame is exactly when a wrong resume escaped. * docs(session): name the inverted fail-open direction at the resume gate * fix(session): keep an unresolved catalog out of the resume host verdict The worktree form of the resume gate resolved the current host through getExecutionHostIdForWorktree, which answers 'local' for a worktree the catalog has no row for. Read as a host, that made every SSH-stamped record look foreign until its repo row landed, contradicting the module's own contract that it reports only a positively-known disagreement. Add getKnownExecutionHostIdForWorktree, which returns null in that silence (no repo row for a git worktree, no folder-workspace row for a folder workspace), and route the gate through it; the pair form already fails open on a null host. The routing resolver keeps its default unchanged. The CI red on the control case was a separate spec race: the ledger wait returned as soon as the ledger was non-empty, and it already held the first launch's `--version` probe, so the control read two probes and gave up before the cold-restore had typed `--resume` (the failure screenshot shows the command running in the pane). The spec now reads only the lines the relaunch appended, anchors on the relaunch's PTY binding and its own probe, and then waits for `--resume` for the control case or a bounded grace for the refusal case. |
||
|
|
945ea33541 |
Revert "fix(editor): evict stale mirrored file tabs (#21363)" (#21368)
This reverts commit
|
||
|
|
ffc812cdce |
Reveal active workspaces with minimal filter changes (#21364)
* Reveal workspaces by adjusting only blocking filters * Update runtime localization catalog * Preserve minimal reveal behavior across catalogs and folders |
||
|
|
9641a1b544 |
feat(mobile-web-bundle): serve the packaged mobile web bundle over RPC (OTA phase A, 3/5) (#21348)
* feat(mobile-web-bundle): serve the bundle manifest and chunks over RPC Two paired-runtime methods on the already-authenticated connection: `mobileWeb.bundle.manifest` returns this install's manifest plus the chunk size it advertises, and `mobileWeb.bundle.chunk` returns one aligned range of one asset with the whole asset's length and hash, so a single chunk describes what it belongs to. `path` is accepted only by exact match against a manifest member, so traversal is unreachable rather than mitigated. Each asset's on-disk sha256 is verified once and the verdict remembered, concurrent first readers sharing one hash. Reads are capped at four in flight per connection, and a disconnected client stops costing reads at the next checkpoint. No SSH or relay proxying: a runtime answers only out of its own install. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the three buildId serializers against each other The canonical serialization exists in the builder, the packaging guard, and the shared contract, because the two packaging scripts run on bare node before any build output exists and cannot import TypeScript. A divergence in any one would reject every honest bundle at packaging, or ship a bundle whose id the phone recomputes differently and re-downloads forever. Proved red by swapping the guard's code-unit sort for localeCompare: five of six cases fail. Exports the guard's serializer for the test; no packaging behaviour changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover every error code and a multi-chunk paging round trip Against a synthetic bundle in a temp dir, because the real builder's largest asset is under one chunk and CI unit jobs never build out/mobile-web. The fixture's script spans three chunks, its stylesheet is exactly one, and one asset is empty, so paging, the eof boundary, and the zero-byte case are exercised rather than assumed. Reads in flight are held by latching `open`, so the four-per-connection cap and an abort arriving mid-read are deterministic rather than a race with a stopwatch. Both were proved red: dropping the abort check after verification fails the abort case, and keying the cap on connectionId alone fails the device-token case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): resolve the bundle root through the AppEnvironment port check:runtime-electron-ratchet caught this: the resolver sat beside getBundledWebClientRoot in src/main/startup and imported electron, and importing it from an RPC method pulled the first electron edge into a runtime graph whose baseline is zero. The runtime has to stay bootable on plain Node. So it reads app.getAppPath() through the port every other runtime module already uses, and moves next to its two callers under src/main/runtime. A host with no environment installed has no install root, which is the same answer as having no bundle. orcad answers getAppPath from its own install root, so a headless runtime that carries the artifact serves it with no special case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover the resolver's two probe layouts directly Also stops exporting the manifest filename, which nothing outside the resolver needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin both methods on the mobile allowlist The scanner only checks mobile-used ⊆ allowlist, and no mobile source calls these until A5, so deleting both entries left every test green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): keep filesystem failures inside the six error codes An asset unlinked or truncated after its verdict was cached reached the client as runtime_error carrying the desktop's absolute install path. Both now answer mobile_web_bundle_asset_changed, with the cause warned host-side only. A short positional read is the truncation case, so it throws instead of paging the client past the end. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): drop the unreachable release-idempotence guard The one caller releases exactly once in a finally; removing the flag left every test green, so it was defensiveness against a caller that does not exist. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): prove a failed verify is not cached as a verdict The verdict cache never invalidates, so a transient read failure remembered as a verdict would poison the asset for the life of the process. Removing the delete left every test green until now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): delete the unsatisfiable manifest params schema The dispatcher substitutes `{}` for absent params, so `z.null()` could never parse; the method declares `params: null` instead. A comment on the method name records why there is no schema. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): fill the read window instead of failing a partial read fs.read may answer short of what it was asked for before EOF, so the previous check turned a legitimate partial read into a spurious asset_changed. The loop mirrors the relay's readFullStreamChunk, which is not imported because it sits behind the relay dispatcher's module graph; only a read returning nothing is treated as truncation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): read the disconnect idiom with the shared predicate isClientDisconnectedError already exports exactly the check the catch needed, so the local error class goes away and the throw returns to the repo-wide idiom. The module doc now says asContractError is a total catch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the four branches no test was holding Each one survived a mutation: the abort check before verification, the per-process manifest cache, the buildId component of the verdict key, and delete-at-zero in the admission map. The last two matter beyond hygiene — a verdict keyed by path alone carries a failed verdict onto the next build of index.html, and a map that never drops a key retains one pairing token per socket. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
4b4ee040df |
perf(relay): index client request aborts instead of scanning every controller (#20052)
* test(relay): measure per-connection teardown and hot-path costs by counting
Both suites replace a would-be duration with the structural fact the duration
was a proxy for, so neither depends on machine load.
The census pins that attach/publish/detach churn returns every per-connection
container to baseline, and asserts the containers actually filled first so a
green cannot come from a probe that never loaded them. It also pins the one
container with no per-client teardown: a publication-ledger entry is reclaimed
only by its own lease, never by closeClient.
The operation counts pin that notifyLegacyCapacity costs one ledger lookup per
active client, that a broadcast costs a fixed number per subscriber, and that
abortClient enumerates every controller rather than the target client's --
which is what makes a full client churn quadratic.
* perf(relay): index client request aborts instead of scanning every controller
abortClient runs on every closeClient and every setWrite. Under the flat map
keyed `${clientId}:${requestId}` it had to walk every controller in the relay to
find one client's, so a full churn of N clients each holding K in-flight requests
cost K*N*(N+1)/2 key visits: measured 50 -> 5,100, 100 -> 20,200, 200 -> 80,400,
400 -> 320,800, exactly 4x per doubling.
Do not "optimise" this back to a scan with an early break. It cannot work: the
matching keys are scattered through the map, so any correct loop still visits
every entry before it can know it is done. Only an index makes teardown
proportional to what the client owns.
`create` now returns an opaque handle carrying the owner, so a release finds its
bucket without parsing a composite string key, and no call site changes.
Also stop building the low-water key array eagerly. `belowLowWater` decides on
the aggregate ceiling first and returns without reading the keys, but the caller
had already allocated an N-element array and N template strings to pass them --
paying most in the loaded case, which is when that short-circuit fires. It takes
a thunk now.
The hot-path test becomes a guard rather than a characterisation: it asserts a
teardown visits only the target client's K controllers and never enumerates the
client index at all, since enumerating it is the old scan. Verified by mutation:
restoring the scan shape fails it with "expected 40 to be +0". It asserts the
maps really hold 160 controllers first, so it cannot pass by never filling them.
* test(relay): make the capacity-thunk guard fail when the thunk is removed
The operation-count test measured an idle dispatcher, where the aggregate ceiling
never short-circuits, so every key is read whichever call shape is used. Reverting
the thunk left all five assertions green -- it guarded nothing it claimed to.
Adds the loaded arm, where the ceiling answers first and the saving exists, and
asserts the client index is not enumerated at all. Reverting the thunk now fails
it with `expected 50 to be +0`.
Drops the ledger-retention case: it asserted a stranded entry SURVIVES close, so
it pinned a capacity leak as a contract and would have broken whoever fixed it. It
also used a key no client-keyed reclamation could match, and touched nothing this
branch changes. The churn census already proves normal closes settle every entry;
the gap is recorded there as a gap.
* test(relay): carry the SAFETY: rationale main's casting gate now requires
Not introduced here: main gained a `typescript/consistent-type-assertions` scan while
this branch sat 432 commits behind, and every `as` in the two probe files this branch
adds is new relative to main, so all 11 land as new findings. Verified by running the
gate on this branch with and without my earlier test commit — 11 either way.
Both files reach past `protected` to count containers, which is the measurement; each
cast now carries the line-specific rationale AGENTS.md mandates.
* test(relay): put the countingIterator SAFETY: directive on the line oxlint flags
The diagnostic points at the `return {` that opens the object literal, not at the
`} as IterableIterator<T>` that closes it, so disable-next-line has to sit above the
statement.
* test(relay): type countingIterator as MapIterator and drop two suppressions
The wrapper only ever receives a Map iterator, so declaring that removes the cast at
both call sites; one irreducible cast stays on the object literal, which cannot satisfy
MapIterator's full surface. Three suppressions become one.
* fix(relay): key the abort index by the id's string form so a string id can still be cancelled
The flat map's template key folded a request id of 7 and "7" onto one entry;
keying the raw value split them, so rpc.cancel (which coerces through Number)
missed a string-id request. Restore the coercion at the index.
|
||
|
|
07e8c851b8 | fix(editor): evict stale mirrored file tabs (#21363) | ||
|
|
660969d191 | Update README downloads badge | ||
|
|
82ca89124b |
fix(lint): exempt the descendant-sweep test shim from the module-mocking gate (#21362)
#20642 and #20645 added src/main/daemon/mock-descendant-sweep.ts and src/relay/mock-descendant-sweep.ts: test-only side-effect modules whose whole body is one vi.mock, imported by 60 suites so mock PTY PIDs never reach the host process table. Their CI ran before the anti-slop gate landed, so main now fails `oxlint --config config/oxlint-anti-slop.json` on every PR's merge ref. File-scoped exemption, like the others in this config, because the root lint scan does not load the plugin and an inline directive would read back as unused. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a61119ceb0 |
refactor(runtime): name the four answers a host probe can give (#21207)
The renderer expressed every non-answer as one nullable `status`, so a probe in flight, a probe that failed, a host that refused us and a retired pairing all reached readers as the same `null` -- and readers spent that `null` on decisions of very different weight, including destructive ones. `RuntimeHostContact` names the four. Nothing changes yet: the connection-state derivation is rewritten on top of it and a 384-case parity table asserts the result is identical to a frozen copy of the old one on every combination of verification, transport, retired, answered and remote-control state. |
||
|
|
355757c947 |
fix(terminal): keep the host's platform through an unverifiable probe (#21188)
The platform a host runs is a fact about the host, not about whether its last probe came back. Reading `entry.status` fell through to the client's platform the moment a probe went unverifiable, so a Windows host driven from a Mac silently started resolving keystrokes and paths with POSIX conventions mid-session -- and switched back on the next successful probe. Same conversion as the four sibling reads, using the same shared reader. |
||
|
|
b6e039dec0 |
fix(settings): read host reachability from the shared verdict (#21206)
Settings > Available Hosts and the repository host-setup section render the same host from the same store entry, but this row derived its own answer from raw `entry.status`. An unverifiable probe nulls that while the transport is still up, so the row flipped to "error" and swapped Disconnect for Connect while the other surface -- which already goes through runtimeHostConnectionStateForEntry -- still showed the host as reachable. One host, two surfaces, opposite answers. A probe that did not come back is not a host that went away. |
||
|
|
edbcf68e53 |
fix(ssh): record the superseded-relay pass the Windows arm abandons (#20045)
* fix(ssh): record the superseded-relay pass the Windows arm abandons `sweepSupersededRelayEndpoints` returned `[]` for every Windows remote host and for every failed listing without writing a line. Both returns are indistinguishable from "this host had no orphans", which is the one thing this sweep exists not to be: its own header says it makes the orphan population "visible and deliberate rather than silent". The Windows population is real. `relayEndpointForHost` hashes the version directory into the pipe name, so an app update strands the incumbent exactly as it does on POSIX, and with `--grace-time 0` that relay keeps its PTYs and agents forever. Measured on a Windows 11 host (awin): the NPFS root lists 262 named pipes from an unprivileged shell, and the count of `orca-relay-*` names goes 0 -> 1 the moment a relay binds, so the endpoints are enumerable; the repo already enumerates them for GC via `relayLivenessProbeCommand`'s `.windows-active-pipe-*` marker scan. Reclaiming them is not this change. `probeRelayEndpointIncumbent` answers `unverifiable` for every Windows path, so nothing here could be classified, let alone reaped, and nothing about the kill path moves. What changes is that an abandoned pass now leaves a trace. * fix(ssh): keep the endpoints a half-run superseded sweep already classified The Windows arm and the failed-listing arm now both leave a line. The loop between them did not: socket 1 could be fully probed and classified, and an exec on socket 2 that threw took `logSupersededRelayFindings` with it — so a half-run pass and a host with nothing to sweep produced the same silence, and socket 1's verdict was lost. Only one failure class can leave that loop, and it is the one that matters: an exec whose SSH channel never confirmed close, which may still be running remotely and which `probeRelayEndpointIncumbent` rethrows by design. Every ordinary probe failure already degrades to `unverifiable` and the pass continues — a test now pins that too, so nobody "fixes" the loop into stopping on an absence of evidence. Findings are logged before the rethrow, which propagates unchanged. The added line says how far the pass got and claims nothing about the endpoints it never reached. * fix(ssh): word the Windows sweep skip so a first install does not read as orphaned The line fired on every Windows relay launch and asserted a population: "orphans from earlier builds are neither listed nor reclaimed" reads as a finding on a machine that has never had an earlier build. The skip is what is being recorded, not a census. |
||
|
|
78a17bb24d |
fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon (#19879)
* fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon
parseHandshakeMessage returned whatever JSON.parse produced, and the daemon
interpolates the peer's version into a log line before any credential check.
A version that is an object with a non-callable toString throws TypeError
there, inside the frame-decoder callback. FrameDecoder.drainTurn wrapped its
synchronous dispatch in try/finally with no catch, so the throw escaped
feed(), escaped the socket data handler, and reached uncaughtException: the
relay daemon exited and every PTY and agent session it held died with it.
Two layers, because only the second closes the class:
- parseHandshakeMessage now requires the string fields each arm carries
(version; expected/got) and rejects a non-object payload. Both readers
share the parser, so neither side can interpolate a non-string again.
- FrameDecoder contains a frame owner that throws on the synchronous turn
the same way it already contained one on a continuation turn: reset the
residue and report one FrameDecoderContinuationError to onError. Every
owner's onError already closes its own connection, so any future throw
of this shape costs one connection instead of the process.
The relay CLI channel gains an explicit onError so a malformed reply still
ends that one-shot command instead of parking it.
* fix(relay): keep the diagnostic the refusal path exists to produce
Two error paths that destroy their own evidence.
`parseHandshakeMessage`'s unknown-type refusal interpolated `String(t)` on a
peer-supplied value: `{"type":{"toString":1}}` makes String() throw "Cannot
convert object to primitive value", so the refusal arrives without naming what
was refused. `describeRelayProtocolVersion` guards this exact hazard two files
away; the sibling was missed.
`runRelayOrcaCliChannel`'s new `onDecodeError` wrote to stderr and then exited
synchronously. stderr is async on a pipe transport, so the one line recording
why the command died could be dropped — the reason relay-handshake.ts already
exits inside its write callback.
* fix(relay): prove the optional handshake field too, not just the required ones
The parser refuses a non-string `version`, `expected` and `got`, then returns the
object with `endpointCredential` unproved — the most pre-auth field on the frame.
It is safe today only by accident: its one reader compares it, and a non-string
loses that comparison. Nothing holds that shape in place, and the next reader to
put it in a log line reinstates the template-literal throw this function exists
to stop.
Present-but-not-a-string is now refused at the parser. Absent stays absent: a
bridge presenting no credential is the common case, and refusing it would close
every unauthenticated-endpoint connection.
Wire-visible delta, deliberate: a peer sending a non-string credential used to get
`orca-relay-handshake-credential-mismatch` and exit 43; it now gets a bare close.
No first-party client can reach it — `runConnectHandshake` types the parameter
`string` and omits it when falsy — and a bare close is the right answer to a frame
that was malformed before any credential was checked.
* fix(relay): carry the SAFETY: rationale main's casting gate now requires
Main gained a `typescript/consistent-type-assertions` scan while this branch sat 432
commits behind, so every `as` the branch touches lands as a new finding. The parser is
the one place the handshake shape is proved, so each cast names the check that earns it,
and the hostile-frame cast in the round-trip test names the fact that it is a deliberate
lie the type system cannot describe.
* test(relay): annotate the hostile handshake frame instead of suppressing a cast
JSON.parse answers `any`, so a typed const expresses the same deliberate lie the
assertion did and the casting gate has nothing to flag. One fewer suppression.
|
||
|
|
5c8540948d |
test(e2e): name the paired-client quit that preserves the profile (#21300)
Quit-without-deleting is closeElectronAppForE2E + cleanupE2EDaemons — dispose's first two steps without removeProfile. The composition is correct today but undiscoverable, and getting it wrong is silent and expensive in both directions. dispose() + reuseUserDataDir yields a FIRST RUN on an empty profile, so every persistence assertion after it reads empty and is indistinguishable from data loss. That produced a phantom data-loss report, live in two write-ups before a diagnostic listing zero session FILES (rather than zero buffers) contradicted it. Reaching for a bare app.close() to skip the deletion hangs instead: it lacks the timeout and force-kill fallback that closeElectronAppForE2E wraps around it, and burned a ten minute test deadline producing no reading at all. Test infrastructure only; no production code. Unblocks restart-persistence coverage for the paired topology. |
||
|
|
066b4951b9 |
fix(terminal): keep a split's real direction when the leaf set moves (#21294)
resolveTerminalLayoutRoot discarded any known tree that did not cover the published leaf set exactly and rebuilt the tab as a flat chain with a guessed 'horizontal' direction, restacking side-by-side panes. The guess is then published, mirrored to every paired client, and written back over the real tree, so the direction is gone from disk. Prune a known tree to the leaves that survive and graft only the leaves no tree places, which is now the sole place a direction is invented and is still reported through onSynthesize. |
||
|
|
a84f16df3d |
fix(mobile): mint one pairing offer per Continue on the sidebar page (#21261)
* fix(mobile): mint one pairing offer per Continue on the sidebar page Step 2 auto-minted as soon as it became visible, which is the same commit that starts the network-interface lookup. The offer therefore advertised whatever address was left over from the last visit (or none at all, so main picked its own default), and when the lookup settled on a different address the refresh handler reminted with rotate: true. Two overlapping getPairingQR calls then raced for one pending credential: main rotates the pending device away for the rotate mint, and orders concurrent offers by arrival at its generation counter rather than by the order the renderer issued them, so the request the pane is waiting on can be the one main decided to supersede. Defer the auto-mint until the interface lookup settles, and keep Step 2 reading as busy while it waits — the sidebar has no separate Generate step the user is expected to reach, so it must still mint on its own, unlike Settings which clears and waits for an explicit press. * fix(mobile): gate the Step 2 mint on this flow visit's address lookup The first attempt gated on a single boolean ref meaning "an address lookup is running". That cannot describe a re-entrant operation: entering the flow, leaving, and re-entering runs two overlapping lookups, and the first to land clears the flag while the second is still out — so the mint went out against the superseded lookup's address and the second lookup then reminted with rotate: true. The same double mint the change exists to remove, one path over. Gate on positive evidence instead. Each flow entry bumps a visit counter; the lookup records the visit it answered (max, so an abandoned visit landing last cannot walk the marker backwards); the mint waits for addressedFlowVisit === pairingFlowVisit, which is false at t=0 by construction and makes exactly one false-to-true transition per visit. The ref is gone and the effect's dependencies now name what it depends on. A superseded lookup's response is also discarded outright, so it cannot move the picker onto an address a newer lookup already replaced — that reselection is itself a remint trigger. The derived busy flag collapses to one clause and is renamed awaitingPairingAddress: it was being passed down as pairLoading while local readers used the real one. It stays separate from pairLoading because that feeds shouldRegenerate in the invalidation hook, where merging them would let a mode switch mint before the address settles. * fix(mobile): put the visit-settled write behind the lookup epoch guard setAddressedFlowVisit was the one completion side-effect outside networkInterfacesRequestIdRef, so a superseded lookup *for the same visit* still marked that visit addressed and released the mint while its own replacement was still pending — the newer address then rotated the offer away. The visit counter cannot see this case: both lookups belong to one visit, and only the request epoch distinguishes them. Reaching it needs a manual Refresh click to beat the commit that disables that button, so field impact is low. The point is that the invariant is now structural instead of resting on a button being disabled in time. Math.max is dropped with the move. Every visit bump starts its own lookup, so the newest request always carries the highest visit and the marker cannot move backwards — the max could no longer be killed by any single mutation, which made it dead code asserting a hazard the guard removes. Also swap the test reset to _resetPairedMobileDevicesCacheForTests, matching the sibling suites: replacePairedMobileDevices is production API that publishes loaded:true and leaves the recovery-listener refcount untouched. * refactor(mobile): make the unaddressed flow visit an explicit null -1 only worked because visits start at 0 and count up; null says "no visit has been addressed yet" without depending on that. Also record at the visit bump why it cannot move into the stage effect: an effect runs a render after Step 2 is visible, so the auto-mint would see the previous visit settled. * fix(mobile): invalidate abandoned pairing mints |
||
|
|
3de77340fc |
fix: apply managed Claude auth to Agent Teams (#21356)
* fix: apply managed Claude auth to agent teams * test: update agent teams auth launch expectation * refactor: derive agent teams auth deletions |
||
|
|
8c6ae79e94 |
fix(relay): stop detached tools on immediate terminal close (#20645)
* fix(relay): sweep detached tools on immediate terminal close * test(relay): reject failed process cleanup queries |
||
|
|
691d9692e6 |
fix(pty): stop detached OMP tools on immediate terminal close (#20642)
* test(omp): add opt-in owned PTY closure probe * fix(pty): sweep detached tools on immediate unrecognized shell close * test(omp): create close probe evidence root in fresh worktrees * test(pty): account for asynchronous immediate descendant cleanup * test(pty): reject inconclusive descendant cleanup probes |
||
|
|
28c32f3587 |
fix(stats): bound retained events during stalled writes (#20941)
* fix(stats): cap retained events before asynchronous persistence * test: use typed access in memory retention regressions --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
d04b05b5c8 |
Detach retained CI and terminal tails from oversized strings (#20960)
* fix(memory): detach retained CI and terminal tails from oversized strings * fix(terminal): detach retained error and reattach string slices * fix(terminal): release oversized recent-output backing strings * fix(terminal): release backing strings held by PTY detectors * fix(memory): own bounded Claude background task labels * fix: detach retained terminal mode scan tails * fix: own retained plugin worker output strings * fix: own incomplete OSC 133 carry strings --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
57e28ccf7c |
fix(runtime): keep absent session tab close intents durable (#21189) (#21277)
* 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> |
||
|
|
1aadf91153 |
fix(runtime): preserve observed exit during explicit terminal close (#21019)
* fix(pty): reconcile daemon exits after synthetic notifications * fix(runtime): preserve observed exit during explicit terminal close --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
c3c051dfa6 |
Release provider children after structured session holds disappear (#20978)
* 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> |