Repository navigation
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthrough
Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The relay teardown changes clean up failed control activation and pending retry timers without an identified regression. The PR is ready to merge. 🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
Full details: Description checkExplanation The description provides detailed context, implementation changes, rationale, tests, mutation results, scope boundaries, and merge-order information. However, it does not follow several required template sections, including an explicit Linked Issue, Visual Proof or N/A statement, AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist. Resolution Add the required template sections. Provide the linked issue in the
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Both defects are real and both fixes are the right size — no speculative machinery, no unused cleanups.
- Disarm the drain retry on pool close (
relay-origin-pool.ts:104).reset()only zeroes the backoff counter;cancel()is what clears the armed timer. Without this, a close with a retry pending left a non-unref'dsetTimeout(up to 5 min) retaining the origin and its closure graph past the quit/sign-out fences. The retry callback was already inert viahandleDrain'sisCurrent()guard, so the leak was timer/handle retention, not post-fence work — correctly characterised. - Close a control whose activation throws (
relay-control-origin.ts:117-125).openControl()cleans up connect failures butactivate()had no equivalent. The reachable trigger (CloudRelayTransport.setGeneration()refusing a generation change while data sockets are attached) throws beforeactiveControlis reassigned, socloseRetiredControl'sactiveControl !== controlguard holds and the freshly opened control is deleted and closed. The drain path already fell back toactivateTarget; this stops the rotation path from leaking one live control per 5–15s retry.
Tests. I verified both new tests are load-bearing rather than theatre by mutating the source locally: reverting cancel()→reset() leaves exactly 1 armed timer and fails relay-origin-pool-teardown.test.ts; removing the rebind try/catch fails relay-control-origin-rebind-activation.test.ts. The getTimerCount() === 0 assertion pins the actual property (no timer survives close) rather than an incidental one that the isCurrent() guard could satisfy alone. Full relay suite (22 files / 179 tests), oxlint on the 4 changed files, and typecheck:tsc:node all pass.
Non-blocking open question. The rotation path still retries a rebind that cannot succeed when the cell hands back a fresh generation while data sockets are attached; it recovers only once the lease expires and the cell drains the socket (re-entering the drain path, which does have the activateTarget fallback). This is a limitation the PR bounds rather than introduces, and the author has called it out explicitly as deliberate follow-up — flagging only so it stays visible on merge.
openrouter/deepseek/deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…ing the creation launch route (#19819) * fix(native-chat): wait for the runtime capability probe before resolving the launch route A worktree created before the renderer's hydration-gated capability refresh runs read the local capability set as null, which resolveStructuredNativeChatSupport treats as a blocker, silently degrading structured native chat to the legacy terminal-backed route. Creation submits now await ensureLocalRuntimeCapabilities(), which probes the local runtime when no answer has landed yet, so the route resolves on an actual answer. Fixes #19154 * fix(native-chat): await the capability probe in the work-item direct launch route too prepareDirectWorkItemAgentLaunch is the fourth creation-flow route owner and already async; a pre-hydration submit-after-ready launch (fix-checks) read the unprobed cache as unsupported and silently degraded to legacy. Draft-delivery launches were unaffected (draft-prompt blocks structured before the capability check). Same shape as the three creation-submit sites. * fix(native-chat): keep the capability probe starting synchronously The broken-bridge hardening wrapped the probe in Promise.resolve().then(...), which deferred window.api.runtime.getStatus() by a microtask. The session-tabs restore deliberately overlaps its inventory RPC with this refresh and relies on the probe already being in flight when refresh returns, so the deferral broke it. The bridge call is synchronous again; a synchronous throw becomes a rejection instead, which is what the wrapper was actually for. * fix(native-chat): hydrate local runtime capabilities at renderer boot The capability cache's only writer was `useLocalStructuredSessionTabsSync`, gated on workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. Every `resolveAgentLaunchRoute` reader treats an unanswered cache as "unsupported", so the answer arriving seconds late is what produces the bare-terminal create in #19154 — awaiting the probe at a route decision guards four call sites but leaves the window open for the three readers that are synchronous and cannot await. Start the probe from the renderer boot chain, ungated, so the answer is cached before any launch route is resolved. The per-call-site awaits stay as the backstop for the residual window and for re-probing after a failed probe. Also: hoist the full-creation probe above its cancel gate so the gate stays adjacent to createWorktree; pin the retry-after-failure, concurrent-ensure and missing-bridge contracts; drop a stale microtask tick and correct two comments that no longer described the code. * test(native-chat): pin the cancel gate around the capability probe The probe added an await to two composer creation paths. Full creation had no gate between the route decision and createWorktree, so the earlier revision opened a window where a dismissed composer still created a worktree; the hoist that closed it was unpinned. Quick creation already gated immediately before runBackgroundWorktreeCreation, so its inline await is safe — pin that too, since nothing asserted it. Both tests fail against origin/main (no probe) and the full-creation one fails against the pre-hoist revision. * fix(native-chat): close the folder-create cancel window the probe opened The probe added the first `await` inside `submitFolderWorkspaceCreate`. On `main` that function ran straight through to `createFolderWorkspace` with no suspension of its own, so its caller's `isSubmissionCancelled()` gate and the create call sat in the same turn. With the probe inline, a composer dismissed while the probe is in flight still creates the folder workspace and launches an agent — the same defect the full-creation hoist fixed on the git path. Resolve capabilities in `folder-submit-orchestration` above its existing gate and hand them down, so the create path's prefix is synchronous again. The parameter stays optional: a caller without a cancel gate keeps the probe. Both new tests fail against `origin/main` and against this branch's previous head; the cancel-window one still fails with its probe-pending assertion removed, so it pins the create, not just the probe. * refactor(native-chat): require pre-resolved capabilities on the folder create path The cancel-window fix in f492064 left its invariant -- a caller that gates on cancellation must resolve capabilities above its gate -- enforced only by a comment, because `hostCapabilities` stayed optional with an inline probe as the fallback. A future caller that owns a cancel gate and forgets the parameter would silently reopen the window twice fixed already, and nothing would catch it: the caller census test pins `resolveAgentLaunchRoute` callers, not this function's, and `exactOptionalPropertyTypes` is off so even an explicit `undefined` is legal. Make it required and drop the now-unreachable inline probe. The sole production caller already passes it, so runtime behaviour is unchanged: the old ternary never evaluated its `await` when a value was supplied. `null` keeps its meaning -- probed, genuinely unknown -- and still degrades to the legacy route; only absence becomes impossible. The launch-route test that covered the removed probe is replaced by one pinning that `null` contract with the cache and the bridge both holding the structured capability, so only the handed-in value can produce the legacy outcome. The cases in the sibling suite are not about the route, so they go through one typed wrapper that supplies the unknown answer rather than repeating it 21 times. --------- Co-authored-by: Merge Sim <sim@local>
…workspace goes (#19970) * fix(worktrees): retire the chat tab of a chat with no child when its workspace goes Deleting a workspace left a chat tab behind for every structured session that had no attached provider child at the time, and that tab came back at the next launch pointing at a workspace that no longer exists. A provider child is scoped to a VISIBLE pane, not to a tab: the hold that keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the child after the release grace. So the sweep's liveness predicate selected only "the chat that is the visible pane in the active workspace, or was moments ago" — which means deleting a workspace from the sidebar while a different one is active left every chat in the target invisible to the sweep, and the `live.length === 0` early return did nothing at all. The durable reference is `visibleSessionIds` in the agent-session record store. Both purges a removal already performs miss it: the renderer drops `unifiedTabsByWorktree` and the main process drops the workspace metadata, and neither touches that index. Startup replays it, restores the session from it and republishes the tab. Worktree ids are path-derived, so a later workspace created at the same path inherits the old chat. Splits the two concerns the sweep conflated in one list. Liveness still decides what to CLOSE and what to refuse over, unchanged. Membership — the same fenced record filter minus the liveness clause — decides what to RETIRE, and covers exactly the complement of the close list so each session's tab is handled once. Retirement runs from `killAllProcessesForWorktree`, past every point that can refuse, not from the structured sweep itself: that sweep is joined BEFORE the unstopped-PTY verdict so a structured refusal can outrank a terminal one, and a tab retired there would still be ahead of a gate that can refuse the whole removal — leaving the workspace in place with its chats gone. * fix(worktrees): retire tabs across all teardown outcomes * test(worktrees): type teardown fixtures --------- Co-authored-by: Merge Sim <sim@local>
…20277) * feat(ai-vault-search): define public contract and service seam * feat(ai-vault-search): add IPC runtime relay and web transports * fix(ai-vault-search): register search IPC at the core handler site ai-vault.ts was two lines over the 300-line max-lines limit; the search handlers belong with the other register*Handlers calls anyway. * fix(ai-vault-search): withhold degraded-root paths from relay status Status carried local filesystem paths over the relay while hits redact theirs. redactStatusForTransport applies the same policy at the same boundary: relay callers keep each root's reason and the array length as the count, so the type only makes root optional. * fix(ai-vault-search): close diagnostic path leak and remove test casts * feat(ai-vault-search): carry an execution host id and per-host outcomes on hits * feat(ai-vault-search): route desktop search by execution host scope, including runtimes * feat(preload): accept an execution host scope on session search * feat(web): answer only for the paired runtime on session search * docs(ai-vault-search): describe execution-host routing and the all-hosts merge * test(ai-vault-search): cover every host scope, the all-hosts merge and wire compat * fix(ai-vault-search): resume every host mid-page so a merged page never drops a hit * fix(ai-vault-search): decode the merged cursor with a schema instead of casts CI's type-aware audit refuses type assertions; a zod record validates the per-host entries and yields the typed map without one. * refactor(ai-vault-search): defer cross-host merged search
* refactor(mobile): migrate settings reads to RpcOperation Replay the settings slice on the landed RPC foundation after rebasing onto main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh task parity snapshots after main rebase Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct rebased declaration parity hash Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): account for main task declaration Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): preserve raw RPC rejection timing Return the transport promise directly and interpret replies separately so sibling Promise.all rejection order cannot change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh parity hashes after timing fix Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): use operation interpreter after raw request * test(mobile): refresh settings migration parity hashes Refresh hook and statement parity hashes for the two task declarations whose settings reads now use RpcOperation request and interpretation. Changed declarations: - useMobileTasksRuntimeHydration: settings.get replaced by settingsRead request/interpret. - useMobileTasksWorkspaceCreateActions: settings.get response handling replaced by settingsRead request/interpret. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…s jumping (#20493) * fix(mobile): stabilize native chat tail following * refactor(mobile): give native chat one tail-follow owner Extract the streaming scroll contract into use-mobile-native-chat-tail-follow, so intent and geometry have a single writer instead of a state/ref pair hand-synced at five call sites. No behaviour change: the existing guards pass untouched. * fix(mobile): fence native chat tail follow through momentum * fix(mobile): repin chat at measured tail --------- Co-authored-by: Merge Sim <sim@local>
…0280) * fix(mobile): preserve delivery ambiguity across transport cutover Let physical close settle requests and retain its error as the cutover cause, copying only an existing delivery-unknown mark. Pin sent and unsent caller outcomes and both cutover predicate carriers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): pin the RpcClient.close() settlement contract close() was declared `() => void` with no stated obligation. That was harmless while migrateTo rejected pendings itself; now that it does not, close() is the retiring generation's only settlement path, so a type-compatible implementation that leaves a request pending strands its caller for good. States the obligation on the declaration and pins it for both trackers the real implementations reject through. Dropping the delivery-unknown flag, dropping the relay mark, or leaving pendings in the map each fail a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cutover cause without a type assertion main's new casting gate rejects `(error as Error).cause`; narrow instead so the assertion still distinguishes a missing cause from an unmarked one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Require padded file-write payloads to end on a Base64 quartet boundary. Cover both RPC methods and padded final upload chunks, and document client compatibility evidence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…0460) * fix(tasks): preserve repository results under GitHub search quota * fix(github): preserve search budget on count fallback --------- Co-authored-by: m4air <m4air@Mac.localdomain>
* fix(ai-vault): stream oversized remote session transcripts * fix(build): bundle streamed JSON parser in desktop main --------- Co-authored-by: m4air <m4air@Mac.localdomain>
…Claude (#20507) * fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude Grok's hook discovery reads ~/.claude/settings.json (and the Cursor equivalent) for vendor compatibility, and that is on by default. So inside every Grok pane Orca's managed Claude hook fires in addition to Orca's managed Grok hook, and both POST the same Grok envelope. The Claude-routed copy lands last and wins, so the pane's agent type is resolved from the POST route as "claude" and no Grok-specific normalization runs for it. Guard the managed Claude and Cursor scripts on GROK_HOOK_EVENT, which Grok's hook runner stamps into every hook subprocess it spawns — including replayed vendor configs — after any user-supplied environment, so a hook cannot spoof it. This mirrors the existing DEVIN_PROJECT_DIR guard in the same script, which solves the identical problem for another agent that imports Claude hooks. Placement is load-bearing: the guard sits after the stdin capture, so Grok's writer never blocks, and before both the spool write and the HTTP POST, so a replayed event cannot leave a spool entry that replays later. The Windows variants jump to the stdin-drain label rather than exiting, because abandoning stdin there hangs the writer. The guard is scoped to agent === 'claude'; OpenClaude reuses ClaudeHookService with its own settings file, which Grok does not replay, so it is unaffected. Verified live against Grok 1.0.25 in a dev instance: the pane's reported agent type goes from "claude" to "grok" on every turn-end, including the hidden follow-up turns Grok runs when background work finishes. The guard pushed hook-service.ts past the 300-line cap, so the script builder moves to a sibling hook-script.ts. That mirrors the existing split under src/main/cursor/, where the service owns install/status and the script module owns script text. * fix(agent-hooks): preserve Windows background worker stdin contract
… a subprocess per keystroke (#15310) Typing in the remote browser pane spawned an agent-browser process per keystroke -- ~160ms each, so a 17-character password took seconds -- and some keys arrived half-formed: F-keys, Insert and ContextMenu dispatched windowsVirtualKeyCode 0, Shift+1 typed '1' instead of '!', and non-ASCII printables reported success while typing nothing at all. keypress now resolves the key name through a US-layout table and dispatches the Input.dispatchKeyEvent pair over the electron debugger, the same transport mouseClick already uses. Two fallbacks keep the old behavior reachable: - a single printable BMP character outside the table dispatches in process as an IME-style event (keyCode 229 with the character as text, the shape composed input already has when it reaches pages) - anything else -- media keys, surrogate pairs, unrecognized names -- goes to the helper exactly as before, and only that path pays for creating the helper session Virtual key codes come from the table, never from the character's own char code: charCodeAt puts '&' on 38 (VK_UP) and '.' on 46 (VK_DELETE), which Blink runs as caret commands that swallow the character. Dispatch failures normalize the way evaluate's already do -- a gone page becomes browser_tab_not_found, anything else browser_error -- because attach and sendCommand reject with plain Errors that the RPC layer would report as runtime_error, and the pane only reclaims a dead page when it sees a browser_* code. Result shape is unchanged and no wire, schema or RPC surface moves, so mixed-version client/server pairs see no difference. Pages can observe the fidelity fixes: Shift+a now types 'A', Shift+1 now types '!', Alt+<char> no longer carries text, and editing keys arrive as rawKeyDown. Each matches what a real US keyboard produces. Verified against the shipped agent-browser 0.27 binary on the same browser: every difference is a fix, nothing regressed. macOS editing shortcuts (Cmd+A) do not fire through either path -- Blink runs those off the native responder chain and neither sends CDP `commands` -- so that gap is unchanged, not introduced. Co-authored-by: Neil <neil@stably.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…20284) * fix(rpc): validate task provider identity fields Validate provider-specific field types while preserving nullable scopes and unknown identity fields. Record the producer census and pin validation with regression tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(rpc): reject a blank GitHub owner or repo normalizeTaskProviderIdentity treats a blank owner or repo as no identity at all, but the schema accepted '' and whitespace-only, so the two disagreed about the same payload. Refined rather than trimmed: trimming would rewrite the parsed value and change what the handler receives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): re-measure the identity evidence counts The blank-field commit added seven tests, so the recorded 74/16/58 described the commit before it. Re-ran both: 81 tests, and the discriminant-only mutation now gives 17 failures / 64 passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): correct the remaining stale gate count The blank-field commit moved the full-RPC total too; 2,463 was the count before it. Re-ran: 278 files, 2,470 passed, one skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(terminal): batch file-link existence checks on their owning host * test(relay): allow additive filesystem capabilities * fix(web): keep terminal file links working under batched existence checks createShellApi omitted pathsExist, so withFallback answered the new batch call with a truthy proxy resolving to undefined and the whole hover batch rejected — dropping every link on lines with an out-of-worktree path. * test(web): assert the shim without type assertions --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <neil@stably.ai>
* feat(native-chat): support workspace file drops * fix(native-chat): report OS file drops that attach nothing #15782 is a silent failure on the Finder route, and that route still swallowed every way it could fail: - the preload handler returned with no feedback when the OS handed us file items `webUtils.getPathForFile` could read no path from (promised or virtual files). It now sends the existing `rejected` payload with a new `unresolved-paths` reason, which the global drop toast names. - the composer's external-attach path dropped the batch with no notice when every path failed authorization, when an upload came back empty, and (new in this branch) when the owner changed mid-flight. Each exit now sets a notice; only a disabled composer stays quiet, because it has no notice surface. Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a drop/IME handler when an SSH connection's generation is gone mid-attach — that is an unknown owner, which the resolver already models as `not-ready`. * refactor(native-chat): one owner-identity check for composer attachments The branch had two near-identical "is this still the same owner" helpers, one per attach route, and they disagreed: the workspace-drop copy ignored the SSH connection generation, so a reconnect between the drop and the IME flush read as the same owner and the path landed on a new connection. Collapses both onto one predicate in the pure ownership module (the store/toast-free seam both routes already depend on), which compares the full SSH expectation and never treats `not-ready` as a match. * perf(file-explorer): resolve drag ownership at dragstart, not per render The virtualized row list resolved the selection's source execution host on every render — the virtualizer re-renders on every scroll frame, so a large multi-selection paid a full projection scan plus a route allocation per selected path per frame, and per visible row on top of that. Only `onDragStart` ever read the result. Rows now receive a resolver they call with the paths they are about to drag. The three copies of the "stamp only if both halves resolve" guard (explorer row, both combined-diff row shapes) collapse into one helper next to the writer. * fix(native-chat): refuse a guarded composer drop visibly The drop handlers claimed the drag (preventDefault + stopPropagation) before checking `disabled`, so a guarded composer told the browser it accepted the drop, left the copy cursor up, and then did nothing — the same silent swallow this branch exists to remove. Dragover now answers `none` when the composer is guarded, so the cursor refuses and no drop event follows. It still claims the event either way: the composer sits inside the terminal surface, which accepts the same drag and would paste the paths into the shell instead. Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already keeps the event off the editor below, so the stronger form only risked suppressing unrelated listeners on the React root. The fake DataTransfer in the test now starts at a dropEffect we never write, so asserting `none` or `copy` proves the handler set it. * fix(native-chat): decide attachment ownership per path, not per batch A queued batch can mix sources — a workspace drop the target host owns and a client-local paste it cannot read — because IME composition holds both until it settles. Collapsing the batch to one verdict refused the whole thing on a remote target, including the drop the user was entitled to make. The verdict now follows the path it belongs to: owned paths attach, client-local ones are refused, and the refusal is reported rather than dropped. A stale owner still refuses everything, since that means the target moved under all of them. Also guards the empty-batch case, which previously read as "every path owned". * refactor(combined-diff): resolve drag ownership from the live workspace The combined diff captured an execution host into the open-file record at tab open and drilled it through three components to reach the row. That host was never persisted, so after a restart every drag from a restored diff was refused until the tab was reopened, and the capture failure was swallowed into an undefined source with no trace. Rows now resolve the owner the same way the source-control rows already do, from the workspace the diff belongs to at the moment of the drag. That deletes the prop drilling, the store capture and its bare catch, and leaves one way to answer "who owns these paths" for every live listing. The file explorer keeps its per-node owner: its tree is a cache that can still be showing a previous host's listing, which is exactly what that field records. * revert(file-explorer): drop the workspace-id tree reset Resetting and reloading the tree when the workspace id changes at an unchanged path is not needed for the drag source to be correct. The tree already records the workspace whose root listing it committed, so a cache left over from a previous workspace stamps that workspace and the composer refuses the drop — the intended answer, reached without touching the reset rule. That rule clears selection, the name filter and undo history, which is more file-explorer behaviour change than this feature asked for. * test(native-chat): stop the external-attach mock hiding new notices The hook's test replaced the whole attachment-owner module with a hand-written stub, so the two notices added alongside the owner-change guards resolved to undefined. Calling them threw inside the async attach loop — an unhandled rejection, which leaves every test in the file reported as passing while the run as a whole fails. CI caught it; a local run reporting only pass/fail counts does not. The mock now spreads the real module, so a notice added later cannot go missing from it, and both owner-change tests assert the string a user would read instead of only asserting that nothing attached. * test(native-chat): guard the last-path owner change on a one-file drop The owner flipping while the final path is authorizing has no next loop iteration to catch it, so the post-loop check is all that stands between a single-file drop and a path attached to a host that no longer owns it — and a one-file drop is the ordinary shape. No test covered that exit. Removing the post-loop check now turns this red; before it, only the multi-path exit was guarded. * fix(native-chat): keep a mixed attachment batch in attach order applyResolvedPaths partitioned a queued batch into a target-owned half and a client-local half and concatenated them. An IME-delayed batch that mixed a workspace drop with a paste made earlier in the same composition was therefore inserted owned-first, so the dropped reference jumped ahead of the pasted one in the draft. Filter against the two verdicts in place instead. Membership is unchanged, the order the user attached in survives, and the two intermediate arrays go away. * fix(file-explorer): name the owner of a dragged path whose row is hidden A multi-selection outlives the rows that showed it. Nothing prunes selectedPaths when a directory collapses, when the name filter narrows, or when dotfiles are hidden, and the drag still carries every selected path. Drag-source resolution read those owners from the row projection, which is built from visible rows only, so one hidden path collapsed the whole drag to an unstamped one and the composer refused it as coming from another workspace. The owner was never unknowable — the dir cache the projection is built from still records which host listed that path. Fall back to it when the path has no visible row. A path in neither (a name-filter synthetic node for a directory that was never listed) still fails closed. * fix(native-chat): ask which workspace the composer serves now The IME-flush ownership check compared the workspace id captured when the drop happened against the same captured value, so for a structured pane the comparison could only ever hold. The live protection came from the host and owner checks beside it; this one asked nothing. Read the id through a ref so the check means what it reads as. A pane whose structured target moves between the drop and the composition settling now refuses the queued path instead of attaching it. * fix(native-chat): ask which workspace an external attach lands on The post-await ownership gate resolved the owner through the render closure, so it re-asked the workspace the attach started in and compared the answer with itself. A tab moved to another workspace mid-authorization passed the gate, and the paths landed in a composer that no longer served that workspace. Read the pane through a ref and compare the workspace identity as well as the owner: two workspaces can both report a local owner, so the owner alone cannot tell them apart. * test(native-chat): read the real notice on a workspace drop The drop tests hand-built their attachment-upload mock and hand-copied the not-ready wording into it, so the assertion tracked the copy rather than the string a user reads: rewording the real notice left all 15 tests green. Spread the real module and override only the owner resolver, matching the two sibling test files in this directory. Rewording the notice now fails the test. * docs(native-chat): restore the hook's doc comment to the hook The workspace comparison landed between the doc block and the function it describes, leaving the comment attached to a type alias. * test(native-chat): cover the upload window for a moved pane The workspace-currency gate guards two windows and only the authorize loop was covered. The upload window is the longer one: the paths go to the worktree the attach captured, so a pane that moved workspaces meanwhile must not receive remote paths living under the workspace it left. * test(native-chat): pin the two untested attachment refusals Refusing an already-blocked target at the drop rather than queueing it had no test: queued paths that can never attach still spend the pending budget, and the next legitimate drop is then turned away for being one too many. Also pins the immediate already-false ownership verdict. Today's only caller settles ownership synchronously so it cannot arrive false, but the hook exports this entry point and the fallback is not a refusal — a false verdict is not "owned", so a remote target blames client-local attachments for an ownership failure. Verified: removing the branch reports the wrong notice. * docs(native-chat): say which rule the ownership refusal follows The per-path comment sat directly above the batch-wide ownership refusal while describing the blocked-target logic below it, so the refusal read as a contradiction of the line under it rather than as the file's stated rule. Name the rule at the refusal: a failed ownership verdict refuses the whole completion, the same way the pending-limit rejection does. --------- Co-authored-by: Merge Sim <sim@local>
…ture (#20521) * test(mobile): record main RPC hooks and regression schedules Add scripted sender recordings, guarded main goldens, reply matrices, lifecycle schedules, settings caller fixtures, and targeted B-seed mutants. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): flush recording user actions through React act Keep lifecycle updates in separate act boundaries while wrapping direct stateful user actions. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compile recorded modules with the Node VM API Use the same trusted-source execution boundary as existing mobile VM test harnesses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pool golden values and hoist pre-divergence checkpoints Golden format version 2 stores each distinct observation field value once in a `values` map keyed by a 12-hex sha256 of its sorted-key JSON, and a checkpoint references five hashes. Output stays pretty-printed; the reader rejects any other format version, resolves hashes back to values, and reports the scenario, checkpoint, field and JSON path on a mismatch. Generated variants now declare where their distinguishing input lands, so checkpoints observed before that point are recorded once in a `.prelude` scenario instead of once per reply partition. Reply matrices, interruption schedules and lifecycle schedules share the primitive, which asserts each variant's pre-divergence prefix matches the base. Equal-but-differently-reached checkpoints are untouched. 17.71 MB / 3,599 checkpoints / 58.4% intra-file duplicates becomes 4.21 MB / 1,961 checkpoints / 23.6%, with every file's set of distinct observations unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the recordings sense deadlines, the recorder, and every family The goldens carried no temporal information, so a request deadline could be cut to a third and all 61 files stayed byte-identical. Every threshold is now straddled by two advances with a checkpoint between them: the 30 s request deadline in both schedule drivers, the 120 ms search debounce in b1, and the 60 s repo-metadata cache TTL. Shortening any of them moves an observation. The record fence pinned product sources but excluded the whole recorder, so --record could rewrite every golden from a modified runner and report the baseline intact. Goldens now pin recorderSha256 over every non-markdown file in the runner plus pilot-scenarios.json, and the fence exemption shrinks to the one directory that digest covers. Mutation evidence covered 3 of 13 mounted operations. There is now one anchored mutant per adapter family, covering 11 operations and 51 of the 61 goldens; the two omitted are the pure async loaders whose entire output is their settlement. Anchors are asserted to match exactly one site, which caught the acceptance mutant silently half-applying against three identical guards. The archived-tree assertion pins each seed's visible state instead of merely differing from main, and error observations carry code and cause when present. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record settlement times instead of straddling deadlines The previous commit made the reviewer's divide-by-three deadline mutant fail by placing checkpoints on each side of the 30 s deadline. That is a patch: a timing change that does not cross a hand-placed boundary stays invisible. Those scenario edits are reverted, and pilot-scenarios.json and schedule-driver.ts are byte-identical to what they were before them. The real defect was that the projection had no temporal dimension, so every settlement now carries startedAt and settledAt in virtual milliseconds on the pinned fake clock. Any transition the product schedules for itself is recorded at the time it actually fires, so a deadline or debounce change of any size, in either direction, moves a recorded number. A checkpoint's own clock is not recorded. It is always the sum of the scripted advances, so it is a function of the scenario rather than of the code under test; run-recording.ts asserts that equality at every checkpoint instead, which costs no bytes and fails loudly if it ever drifts. projectionVersion is 2 and all 61 goldens are re-recorded. With the added timestamps stripped, the distinct-observation set is identical to the previous recording, so the change is purely additive. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): probe the repo-metadata cache inside its TTL window Recorded settlement times cover thresholds the product schedules for itself, but not one it only consults when something else makes it act. The repo-metadata TTL is the single such case: with probes only at 0 s and 60 s, a 20 s TTL and a 60 s TTL are both expired at 60 s and record identically, so a 3x cache-lifetime regression was invisible. settings-repo-cache-expiry now probes the cache at 59 s as well. This is coverage, not a substitute for recorded time: it bounds how small a TTL reduction is visible rather than making the reduction itself observable, and the README says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the reply shapes a host can send, not a cross product The reply matrix froze ~26 malformed envelopes crossed against every consumed field and three boundary kinds, which is 163,925 lines of JSON pinning accidents on inputs no desktop produces. `successResponse` always sets `result`, so a JSON wire has no explicit-undefined slot, and no mounted handler returns a number, a string, an array, a bare `{}` or a boolean: `settings.get` returns `{settings: ...}`, and the seed methods return an object or nothing. Each family now runs nine witnessed partitions once, with no field cross: a normal result, an absent result, `null`, an inner `{ok: false}` envelope with a string or an object error, an inner envelope missing `ok`, an outer refusal, `method_not_found`, and a transport rejection. `null` stays because `linear.getIssue` returns it for a missing issue and b2 is a shipped null-result bug; it is also what carries the one named delta these goldens record. `run-step1-exit.ts` had zero callers and shelled out to the same two Vitest files as `rpc-recording.mts`, so it and its README paragraph go, along with `MUTATION_NAMES`, which only it read. In the module loader, the `rpc-delivery-ambiguity` escape is measured dead: over every scenario, mutant and reference run it was taken once, by the test that existed to take it. Golden comparison already fails loudly if a mounted module ever imports the marker, so both go. The history-panel exposure moves into a declarative table beside the mutation anchors, leaving the loader with one source-text mechanism and no per-file branch. The VM stays. Mount adapters load product sources from an arbitrary `root`, and the archived bcba08b tree is bare `mobile/src` and `src` with no package.json and no node_modules, so no bundler-resolved import can reach it and the seed rejection gate cannot run without it. Direct import also swaps a 38-module lazy graph for a 338-module eager one behind 20 native mocks, because `mobile-tasks-dependencies.ts` re-exports from `react-native` and four other native packages and `export *` enumerates. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): treat the recorded lockfile hash as provenance, not an oracle Every golden pinned `lockfileSha256`, so any dependency bump on main failed all 61 comparisons on the merge commit while the traces were identical. A dependency that changes behaviour changes the trace itself; one that does not must not fail a candidate. `platform` already had this exemption — `lockfileSha256` joins it. Recording still refuses to run unless the lockfile matches the pinned baseline, so goldens are still produced under frozen conditions. Verified against main's lockfile: 85 passed, previously 61 failed. The declared mutation set still reports every mutant killed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc): cover repeat queries and settings refresh boundaries Add three scenarios, preserve existing traces, remove unreachable archived checks, and document observed mutation kills and remaining adapter limitations. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): keep the known-open holes, drop the review transcript The audit file was mostly a point-in-time record of mutation runs that had already happened, in an artifacts directory, where it would go stale on the next scenario change. The durable part is which holes are still open and why they cannot be reached, which belongs beside the runner it describes. Markdown is outside recorderSha256, so no re-record; 88 passed | 3 skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): stage the nine RPC probe scenarios and goldens These existed only on one machine's /tmp. Landing them verbatim first so a reboot cannot lose them; a follow-up commit moves them into the suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fold the nine probe scenarios into the recording oracle The probes were env-var invocations over loose /tmp manifests. They now live in pilot-scenarios.json and rpc-foundation/goldens, so `pnpm --dir mobile test` runs them with no flag to remember. Re-records every golden against main (22f56f7). Two causes: - #20280 gave LogicalClientCutoverError the delivery-unknown mark and its cause, so nine cutover/interruption goldens now record `isRpcDeliveryUnknown: true` plus a `Connection closed` cause. The other 55 are byte-identical after 260 commits of main. - #20499 replaced the five anchored raw-envelope reads with typed operations, so those mutation anchors matched zero sites. Each is re-anchored at the same defect's new home; bot-overrides moves to the shared reader that now owns it. probe-hole-witness.test.ts pins hole and closure together: a probe must kill its mutation and every pre-probe scenario of the same operation must still survive it, so a redundant probe fails instead of accumulating. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): list the recording harness in the raw-port inventory main's #20026 boundary test fails on any non-test file that reaches the raw request port and is not inventoried. The oracle's scripted transport is exactly that — it drives the real tracker and logical client — so it belongs in OWNERS beside the supervisor fakes, not in the step-4 pending backlog. Also states what the oracle covers, the two holes it was blind to until the probes, and the step-4 runbook. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the goldens to the tree that recorded them The inventory entry is a fenced product-tree edit, so --record refused against main's sha. Baseline now names the branch commit the goldens were recorded from; the next re-record after this lands bumps it to the merge commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry a SAFETY rationale on every recorder cast main added a changed-code casting gate after this branch was cut, so 45 `as` sites in the recorder read as new findings. Each now states why the assertion holds; they cluster into five reasons — recorded observations are RecordedValue by construction, parsed manifests and goldens are validated on the next lines, interned pools resolve their own hashes, a VM-evaluated module has no static type, and the mount adapters supply only the members each hook reads. Re-records the goldens: the comments move recorderSha256. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): state the measured blindness, not the assumed one Applying each mutation to real product source shows the two holes are not equal. The reorder is invisible to 83 of 84 tests and only a probe sees it. The refusal blanking is also caught by the family reply matrix, because a refusal from cold publishes null over a non-null initial value — an observational gap, not a detection gap. Says so rather than letting the stronger claim carry both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close three ways the oracle could pass without checking All four review findings were real; three let the oracle report green while verifying less than it claimed. - The baseline guard used `git diff --quiet`, which ignores untracked files, so an untracked module under mobile/src or src/shared could change resolution while a golden still recorded a pinned baseline header. Adds a `git ls-files --others` check over the same paths, recorder still exempt. - The determinism loop read `Number(env ?? 2)` unvalidated, so RPC_FOUNDATION_DETERMINISM_RUNS=0 skipped the body and 57 tests passed having recorded and compared nothing. Now requires an integer >= 2. - Cleanup-time observations were dropped: every checkpoint clones the effects array, so anything appended during dispose or the final flush never reached a golden. Warns and documents the six scenarios that hit it today; recording them changes every golden and is its own change. - Two SAFETY rationales described each other's assertion. Swapped. Goldens re-recorded for the recorder-digest change: 73 files, one header line each, no recorded observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record teardown observations as a cleanup checkpoint Each checkpoint clones the effects array, so a rejection or state write produced by dispose, the transport teardown or the final flush landed after the recording was built and never reached a golden. An unmount leak is exactly what this oracle exists to catch, so teardown now runs on the recorded path and anything it observes becomes a checkpoint with id `cleanup`. State is captured before dispose, since the operation is gone afterwards. Six scenarios were dropping observations, across five goldens: projectRowDetailError, projectMutating, hostLabelById, hostPlatform, workspaceAgent, workspaceAgentOverridden, creatingKey, selectedAgent, agentOverridden and error. Those five gain a cleanup checkpoint; the other 68 goldens change by their header line only, so no existing observation moved. Also fixes the README's own formatting, which failed `format:check`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…anes (#20525) * refactor(attention): move the agent attention boundary off terminal panes The completion-attention pipeline asked PTY questions inline, so a non-terminal agent surface was structurally excluded from unread, delivery and acknowledgement. Extract a provider-neutral policy under `src/renderer/src/attention/` that reaches every surface fact through an adapter, and move the PTY-shaped predicates (`hasLivePtyForNotification`, `isCurrentLivePaneKey`, `isCurrentKnownPaneKey`, `isVisibleForegroundPaneKey`, plus the leaf/foreground resolution the auto-ack scan did inline) into `terminal-attention-surface.ts`, now their only call site. Unread markers carry the reason that wrote them (`agent-completion`, `terminal-bell`, `manual-mark-unread`, `legacy`); an unclassified boolean still on live state reads as `legacy` rather than being guessed or migrated. Main's `notifications:dispatch` closure becomes a delivery service with injected collaborators; the IPC handler is a thin adapter. Behaviour is unchanged: unread is still written before main's desktop gate, the tray dot before the cooldown/focus gates, and mobile fan-out before the desktop early returns. * fix(attention): recognize classified tab unread markers
* fix(auth): distinguish failed sign-ins from user cancellation * test(mobile): fix conditional registration lint and refresh recorder fingerprints
* perf(skills): bound WSL installed skill discovery * fix(skills): preserve bounded discovery correctness * fix(skills): bound WSL metadata prefilter reads * fix(skills): isolate absent discovery cwd cache keys * test(skills): adapt WSL discovery mocks to runner * fix(skills): preserve filtered discovery fallbacks * fix(skills): share filtered scans and preserve WSL inventory * fix(skills): share WSL scans without losing skill aliases * fix: preserve skill metadata and retire filtered peer caches --------- Co-authored-by: m4air <m4air@Mac.localdomain>
* fix(git): skip upstream remote probes when the remote is absent Issue and PR resolvers listed remotes by probing `git remote get-url upstream` on every poll, including origin-only clones where that remote cannot exist. List remotes once, cache against git config, and skip the probe unless `upstream` is present. * fix(git): avoid stale remote probe cache entries * fix(github): observe origin repository probe failures * fix(github): observe verified origin probe failures * fix(github): skip missing upstream probe for PR lists * test(github): scope the #9171 lazy-resolution guard to default-branch commands The guard asserted that no git command runs for an open PR, using "no git at all" as a proxy for "no default-branch resolution". Remote-name listing is a separate concern, so allow it and keep every other command forbidden; the symbolic-ref/rev-parse resolution this issue is about stays unreachable. --------- Co-authored-by: Neil <neil@stably.ai>
…19837) * fix(orchestration): retire read deliveries and clarify mailbox recovery * fix(orchestration): simplify delivery recovery and update nudge contracts * test: align orchestration check help expectation * refactor(orchestration): derive delivery eligibility from messages * fix(orchestration): validate live consumers and simplify batch revocation * refactor(orchestration): keep deliveries.status and derive eligibility without a column drop The outstanding_deliveries view now reads status = 'outstanding' plus unread membership, so v41 only drops uniqueness from idx_deliveries_one_outstanding and adds the view and trigger. Older binaries can still open the database. Removes the column-drop migration, the v40 test fixture and hasColumn guards, the fenced skew probe, and the unrelated nudge-text change. * docs(orchestration): drop delivery storage reference The compatibility caveat it existed to explain no longer applies; the view and index comments carry the remaining rationale. * docs: revert unrelated formatter churn * test(orchestration): verify historical database downgrade round trip
* fix ssh relay incumbent probe timeout * test ssh relay unconfirmed probe termination * fix(ssh): preserve connect evidence while bounding lsof * fix: preserve uncertainty when relay holder enumeration fails * fix(ssh): supervise lsof helpers and preserve partial holder evidence * test(ssh): prevent GC racing unconfirmed probe cleanup * refactor(process): keep POSIX lsof supervision in process owner * fix(ssh): confirm census cleanup and handle probe startup signals * fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts lsof warns to stderr about mounts it cannot stat, and any stderr byte forced the holder enumeration to 'unavailable' — making the 'exited' verdict, and so husk reaping, unreachable on those hosts. Pass -w to suppress the warnings. * Revert "fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts" This reverts commit 10d7b0d. Passing -w is correct in isolation, but it activates a previously dormant path: in the SSH docker e2e lsof warns about the container filesystem, so every probe used to degrade to 'unavailable'. Suppressing the warning lets holder enumeration succeed and the takeover/reaping path run for the first time there, and 'e2e / ssh docker watcher isolation' then hung to the job timeout (52m, vs 28m passing on the parent commit). The underlying gap is real and still open: on hosts where lsof always warns, the 'exited' verdict and husk reaping stay unreachable. Fixing it needs the reaping path understood in that environment, not just the flag. --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai>
…14555) * perf: avoid repeated remote import capability probes * test: cover cold remote import compatibility probe * fix(remote): fence imports across runtime reconnects * fix(remote): bind import proof to connection * fix(remote): fence import routing by runtime identity * test(remote): remove unsafe import fixture assertions - type remote RPC mocks at declaration so call arguments stay checked - narrow upload params before reusing generated temp paths --------- Co-authored-by: Neil <neil@stably.ai>
…#20523) * fix(grok): announce a completion once, when Grok is actually finished Orca pinged on every Grok turn-end. Grok runs turns the user never asked for: when a background task finishes it wakes itself, does a little work, and ends another turn. One request produced several pings. Grok already reports, on every turn-end, whether it still has work outstanding. Read that instead of trying to classify which turns are "real": backgroundTasks absent -> silent, this is the session-end tail StopFailure / StopCancelled -> announce, a failure is never hidden stopHookActive -> silent, a Stop hook is keeping it working a shell task or subagent running -> silent, the work is not done otherwise -> announce Nothing here knows what an auto-wake turn is. A turn that ends with work outstanding stays quiet; the later turn where that work is finally done is the one that announces. That is also why this survives the case where Grok completes a user's goal inside one of those turns — prefix-based suppression would have silenced it. Monitors and scheduled entries are deliberately not counted as outstanding work. They can run indefinitely, so counting them would suppress a user's completion permanently, and a lost ping is worse than an extra one. Also registers StopCancelled, which Grok fires instead of Stop on a user interrupt, a declined permission, --max-turns, or a no-progress bail-out. Orca never subscribed to it, so those turns were reported as successes. Also removes a stale notification matcher that searched for prose the shipping binary never sends; the typed notification kind is matched instead, and neither idle_prompt nor task_complete is treated as a completion. Needs-input behaviour (permission prompts and ask_user_question waits) is unchanged and stays ungated by background work. * fix(grok): never hide a failed or cancelled turn behind the background-work gate The announce predicate checked field-absence before terminal outcome. Grok's StopFailure and StopCancelled payloads carry no background inventory at all, so the absent-field branch — added so the session-end tail stays silent — fired first and silenced every failure and every cancellation. That inverted the rule it was meant to serve. Before this series a cancelled turn at least surfaced as a (wrong) success; gated this way it surfaced as nothing. Terminal outcome is now checked first, so a failure or cancellation announces regardless of what other fields the payload happens to carry. The existing tests passed straight through the bug because they built failure payloads with a backgroundTasks field Grok never sends for those events. They now model the real payload shapes, verified against the provider's payload definitions and the captured envelopes. * fix(grok): settle completion from provider lifecycle state * fix(grok): fence stale turn ends without prompt ids
* feat(native-chat): add provider-aware fast mode * chore: drop unrelated formatter churn from the merge pnpm format reflowed pnpm-workspace.yaml quoting and a source-scan test that this PR does not otherwise touch. * fix(native-chat): review fixes for provider-aware fast mode Review pass over the Fast mode work. Claude reads its model catalog once per option write. The admit check, the effort guard and the Fast guard each took their own `list_models`, so a model write with Fast on paid two round trips for one list and let two guards answer from two different catalogs. The guards are now pure over a single read. Claude no longer refuses a Fast enable when the catalog identified nothing at all. An empty list is not evidence against a model -- the same rule the model admit-check already applies -- so a CLI that cannot answer would otherwise have Fast refused on every model. A catalog that did list the model and stayed silent about Fast is still not positive evidence and keeps refusing. Codex refuses a direct `serviceTier` write instead of accepting one the next turn discards. The turn derives the tier from `fastMode`; the key still restores so a session persisted before Fast existed migrates. Both option surfaces return a cached snapshot again. `SessionOptionsSurface` is read through `useSyncExternalStore`, whose contract is a stable snapshot, and rebuilding it per call breaks that for any consumer wired that way. Also records two decisions that were emergent rather than stated: routing Standard when Fast is on but no tier is named yet, and what a readback disagreement does and does not prove. Quality gate: merges the duplicate imports static analysis flagged, adds SAFETY rationales for two pre-existing casts the changed-code gate now sees, and drops a new assertion in favour of a checked narrowing. * fix(native-chat): read Claude Fast state from the session frame A fresh Claude session reports `fastModeState` while the settings readback still has no `fastMode` boolean, so the two are not redundant -- the frame answers at a moment the boolean has none. The picker fell back to "value unknown" and asked the user to disambiguate what the provider had already reported, and the state it reported had no reader at all. Falls back to the frame only when neither a pick nor the settings readback answers. `cooldown` throttles routing rather than clearing the pick, so it reads as on; reading it as off would flip a control nobody touched. Display only. The launch seed is untouched: an unset Fast preference still seeds nothing, which its own guard continues to pin. * perf(native-chat): skip the model catalog read when turning Fast off Turning Fast off needs no support evidence, so the read only cost a round trip — and restore replays a stored `false` on every acquire. Also narrows the alias-matcher comment: the effort and admit guards match on alias and resolved id only, so calling it the sole matcher overstated it. * fix(native-chat): clear a Claude Fast block once the child stops reporting it The child omits fast_mode_disabled_reason entirely when nothing blocks Fast and never sends a null, so requiring the key back latched the first reason for the session's life: switching to a model that disallows Fast and back retired the control for good, leaving a session running Fast with no way to turn it off. A frame that reports state without a reason is the all-clear. * test(native-chat): cover the mobile structured option hook useMobileStructuredAgentOptions gained generation fencing, a pending-write guard and a post-write options refresh with no test file. Pins the concurrency contract and the fast mode round trip: - a superseded options read is dropped instead of overwriting newer state - an overlapping write is refused and the pending guard is released after - an accepted same-fence write reads options back and applies the result, and a different-fence write does not - a boolean fastMode pick reaches the wire encoded and is remembered decoded - no Fast row when session support, catalog support or the model capability is missing Each behaviour was ablated against the production logic to confirm it fails without it. No production code changed. * feat(native-chat): render a boolean session option as one toggle On and Off were two radio rows under a header repeating the option name, so a binary choice cost three lines and two clicks to read. It is now a single switch row that owns its label, on desktop and mobile. An unknown value keeps its caption: a switch cannot say "unset". * fix(native-chat): resolve a boolean option's display value at the producer A boolean session option reached the UI in three states while its control had only two, so the renderer apologised for the gap with a "Current value unknown" caption beside a switch that had already collapsed to off. For `thinking`, whose catalog default is on, that caption sat next to a switch asserting the opposite of what every composed dispatch assumes. One expression fed both the displayed value and the option's provenance. Split them: the boolean descriptor now always carries a value, resolved to the same `values[id] ?? defaultValue` that buildNativeChatSessionOptionCommand already composes, while `valueSource` is untouched and still records whether anything confirmed it. `kind.currentValue` is required on the boolean arm so the third state cannot come back. The launch path is unaffected: resolveAgentSessionOptionLaunch and buildNativeChatSessionOptionCommand build the composed `--model` argument from the caller's picks and the catalog, never from a descriptor. Both surfaces mark an unconfirmed value instead of captioning it, and the two reasons stay distinct — `default` says the catalog value is what a launch will send, `unreported` says nothing has told us anything. Only `unreported` is reachable in the structured lane, where the agent may be routing a tier we have never been told about, so the two never share a label. * fix(native-chat): let assistive tech read the option value marker The marker was aria-hidden next to an explicit aria-label, so the label already won the accessible name and hiding it only cost screen reader users the default-vs-unreported distinction that sighted users get. It is now referenced by aria-describedby, which keeps the name Fast mode. Mobile's summary row said "Not set" for a boolean while the sheet behind it showed the switch on, so the two screens disagreed. A boolean always has a value; the summary states it and the sheet's marker qualifies it. * chore(i18n): drop the On/Off option strings the switch row retired Replacing the On/Off radio pair removed the only call sites for these two keys. i18next cannot rebuild a key with no call-site default, so leaving them in the catalogs forced them into the boot bundle as dead weight. Removing them shrinks it by two entries instead.
…lugins (#20912) import/no-duplicates was reachable only through the repo-wide CI audit, so an author's first signal was a red static analysis job after push.
…21143) * fix(native-chat): open the message rail panel on the current message The rail's hover panel mounts fresh at scrollTop 0 every time it opens, so in a long thread it showed the top of the conversation instead of where the reader actually is. It already knew which row was current — activeId drives the highlight — it just never scrolled to it. Attach a ref to the current row that calls scrollIntoView({ block: 'nearest' }). Radix unmounts popover content on close, so ref attachment is the open edge; it also re-fires when a different row goes active under an open panel. * fix(native-chat): keep current rail item focused * fix(native-chat): resync rail after list changes * fix(native-chat): own focus across retained rail opens
#21147) * fix(relay): skip boot-time DDL when the catalog already has the object CREATE INDEX IF NOT EXISTS and ALTER TABLE ADD COLUMN IF NOT EXISTS take their relation lock before the server evaluates the existence test, so a boot on an already-migrated database still joins the lock queue. Relation locks are granted in queue order, so every writer queues behind it. The shared runner now asks pg_catalog whether the index or column is already there and skips the statement when a row comes back, and 55P03 is no longer retried by default: with the pre-check ahead of it, a lock timeout means the object is genuinely missing and each retry re-enters the queue. Push keeps the old retry behind an explicit option. * fix(relay): tie the index pre-check to its table and fail on an unreadable target Three defects found in review of the auth reference implementation: - The catalog query matched an index by name inside the table's namespace without checking it belonged to that table. Index names are unique per schema, not per table, so a same-named index on a sibling table answered yes and the real index was skipped forever. Added i.indrelid = t.oid. - Lock-target derivation read a keyword sitting in an identifier position as the object name: CREATE UNIQUE INDEX CONCURRENTLY ON t(c) yielded the name CONCURRENTLY, and ADD COLUMN IF NOT EXISTS with no column yielded IF. A wrong target is worse than none, so keywords are now excluded and an index or column statement whose target cannot be read throws at boot with the statement text instead of falling through to the lock path. - A concurrent-create collision retried the CREATE INDEX, taking SHARE on the table again for an object another director had just finished creating. The catalog is re-asked instead and a present object counts as skipped. * fix(relay): pre-check constraint swaps so a warm boot sends no DDL at all The two ALTER TABLE constraint statements were the last lock-taking statements without a pre-check, so every boot still took ACCESS EXCLUSIVE on relay_region_rehome_attempts twice. A lock target now carries the catalog answer that means there is nothing left to do. ADD CONSTRAINT skips when pg_constraint already names it; DROP CONSTRAINT IF EXISTS is the inverse and skips when it does not, because nothing to drop is nothing to do. The match is by name only: the CHECK body is generated from RELAY_REGIONS, so comparing it would re-run the swap on every region change. Changing a definition under the same name is an operator migration, and the rule comment beside SCHEMA says so. A bare DROP CONSTRAINT gets no target and throws at boot, because skipping it would swallow the undefined_object the server is supposed to raise. The census invariant is now that every lock-taking statement has a pre-check, with no exceptions, and the warm-boot Postgres test asserts zero statements sent rather than two. * fix(relay): refuse a multi-action ALTER TABLE instead of pre-checking its first action `ALTER TABLE t ADD COLUMN IF NOT EXISTS a TEXT, ADD COLUMN IF NOT EXISTS b TEXT` derived the target for `a` alone, so once `a` existed the whole statement was skipped and `b` was never added. The first subcommand parses, so neither the parse throw nor the census caught it. A lock-taking ALTER TABLE with a comma outside parentheses, quotes and comments now throws at boot. One action per statement, or no pre-check is possible. Commas inside a parenthesised type, a CHECK body, a quoted default or a comment are unaffected, and push's 18 statements still parse. * fix(relay): strip every comment before classifying, fold catalog names, count brackets Four findings from the bot reviews on #21147: - A comment between two keywords (ALTER TABLE t ADD /* note */ COLUMN c TEXT) was invisible to both the classification regexes and the must-parse shapes, so the statement got no target AND no throw and ran with no pre-check. Every comment is now stripped quote-aware before classification, nested block comments included. The server is still sent the original text. - hasTopLevelComma counted parentheses but not square brackets, so ADD COLUMN c bigint[] DEFAULT ARRAY[1, 2] read as two subcommands and failed the boot. - bareIdentifier split a qualified name on '.' regardless of quoting, so "a.b" became b", and it kept the written case while Postgres folds an unquoted identifier to lower case before storing it in relname, attname and conname. The name is now tokenised quote-aware and folded, with the qualified table text still passed to to_regclass as written. - sqlWithoutLeadingComments is renamed sqlWithoutComments to match. Relay's 74 statements and push's 18 all still parse, and no relay target name changed: every identifier there was already lower case. * fix(relay): treat a dollar-quoted body as opaque in both scanners A comment marker, comma, parenthesis or bracket inside `$$...$$` or `$tag$...$tag$` is text. The closing delimiter has to match the opening tag exactly, so an inner `$$` inside a `$tag$` body is more text rather than the end, and a tag cannot start with a digit, which keeps a `$1` placeholder from reading as an opener. Relay's pg_stat_statements DO block is the only dollar-quoted statement in the schema, and it now survives the stripper byte-identical. A test asserts that against the real statement.
…ring probes (#19952) * fix(mobile): a scope refusal is not a missing method on the Relay pairing probes The desktop's mobile allowlist gate runs before its RPC dispatcher, so a method an older desktop predates is absent from both and the phone is answered `forbidden`, never `method_not_found`. Keying the "too old for Relay, stay on LAN" fallback on `method_not_found` alone therefore never fired against the exact desktop it exists for: first-time pairing threw instead of committing a LAN host. `isPairingRelayRpcUnavailable` accepts both codes at the three pairing probe sites. It is pairing-scoped on purpose - `isMethodNotFoundRefusal` has four other consumers that must keep reading `forbidden` as a refusal, not as absence. The main-side test pins the claim the fallback rests on: the dispatcher really does answer `forbidden` to a mobile-scoped device and `method_not_found` to a runtime one, and this build allowlists both probes, so `forbidden` on either can only mean an older desktop. * fix(mobile): leave a breadcrumb when a desktop refuses relay pairing The LAN fallback now commits a host instead of throwing, so the refusal code was the only record of why a phone ended up without a relay endpoint and nothing wrote it down. Log it on the path that swallows it. Narrow `isPairingRelayRpcUnavailable` to the two codes it matches rather than to `RpcFailure`: a plain failure guard would collapse the *false* branch to `RpcSuccess`, which a refusal carrying any other code still reaches. Rename the `'method-not-found'` sentinel in the direct-upgrade reader, which stopped describing what it covers, and correct two comments that named a `method_not_found` mechanism the desktop cannot produce for these methods: both probes have been allowlisted and registered by the same commit since Relay landed, and an unwired pairing provider answers `runtime_error`. * docs(wire): record that the mobile surface refuses by scope, not by absence Two comments cited this page for "a scope refusal is not a missing method" and the page did not say it — the only nearby statement says the opposite, because it describes the runtime-scoped surface, where the dispatcher does answer `method_not_found`. The allowlist gate makes the mobile surface the exception, and the harness does not run that surface, so this note is the only record. * docs(mobile): name the pairing site the scope refusal actually reached The comments and the wire-compat note said this fixed first-time QR pairing. It cannot: the `relay` block on the pairing offer, both RPC handlers and both allowlist entries all landed in 77b154d, so a desktop old enough to refuse the probe also omits the offer block, and that flow commits a LAN host without ever probing. The site that reached is `upgradeDirectMobileRelay`, which re-probes every LAN-only host on reconnect: the refusal threw into the controller's swallowing catch, so the write-once journal it had just written — and the pending resume secret in it — was never retired. Also drop two overclaims: the phone's Files and Git fallbacks have read both codes since they shipped, so this is settled practice rather than a new rule, and the reason the allowlisted-but-unregistered case cannot ship is mobile-rpc-allowlist.test.ts, not a convention about what lands together.
… the transport (#20053) * fix(runtime): bound the remote-runtime connect against an unreachable host A host that is powered off or firewalled black-holes the TCP SYN, so the remote-runtime WebSocket neither opens nor errors. The Node-side transports set no connect bound, leaving the caller's whole-request timeout as the only one: every `orca <cmd> --environment <unreachable>` sat silent for 60s before failing with a generic `runtime_timeout`. Measured on an unreachable paired host (win-lowspec, SYNs dropped): terminal list / worktree list / repo list / status each took 60.19-60.26s; the same command against a reachable host answered in 0.24s. So this was the shared transport, not one command. Pass `handshakeTimeout` at the three shared remote-runtime WebSocket construction sites, which `ws` applies across TCP connect and the HTTP upgrade. The value matches the bound the browser transport already used. The failure keeps code `remote_runtime_unavailable` so the existing transport-loss classification in terminal-process-inspection still applies, and the message names the endpoint and stops at "unverifiable" — per docs/reference/ssh-execution-boundary.md, loss of contact is never evidence that the host's work stopped. * fix(relay): bound the control socket's connect phase at the transport The relay control socket was constructed with no `handshakeTimeout`, the same gap fixed for the remote-runtime transports. It was not a live defect: the class-level `connectDeadlineMs` (15s) also covers a stalled connect, and that deadline does fire — its `unref()` is safe because the pending TCP connect is itself a ref'd libuv handle that holds the event loop open. Measured in a bare Node process: unref'd timer with an empty loop never fires (exit at 0ms), but the same timer alongside a black-holed connect fired at 2003ms. It was a defect waiting on a refactor. The two bounds cover different phases, and the class deadline covers the connect phase only incidentally. DO NOT REMOVE EITHER BOUND AS REDUNDANT. They are not. Proven by mutation: - Remove the transport bound -> a stalled *connect* falls through to the class deadline, rejecting with `relay_control_connect_timeout` after the full deadline instead of the transport error. - Remove the class deadline -> a stall during the *proving* phase (socket open, host proof never answered) is unbounded; the incumbent test hangs 30s. `handshakeTimeout` cannot see that phase at all. Reuses `remoteRuntimeConnectOptions` rather than forking a second helper, and moves the construction into `relay-control-socket-factory.ts` so a caller that needs a relay control socket gets the bound instead of re-deriving an unbounded one. `handshakeTimeoutMs` is settable apart from `connectDeadlineMs` so a test can stall the connect alone and assert which bound produced the rejection — error identity, not elapsed time. The connect-bound ratchet now covers the relay site and asserts the site still resolves, so an allowlist that silently stopped matching cannot pass vacuously. * fix(lint): carry SAFETY rationales for the connect-bound casts main tightened typescript/consistent-type-assertions to assertionStyle: never, which the rebase brings onto these added lines. Dropping the generic default is not typeable, so each cast keeps its own rationale. * fix(runtime): keep the bounded connect failure inside both message gates The connect bound's new wording dropped out of the two gates that classify remote-transport failures by message text, and those gates are the only ones that run on the path the bound made reachable. `subscribeRemoteRuntimeTransport` reports a connect failure by *rejecting* the subscribe promise, and that rejection crosses `ipcMain.handle`, which keeps only the message. The renderer then classifies it with `RECOVERABLE_MESSAGE_FRAGMENTS`. `Could not reach the remote Orca runtime at …` matched no fragment, so it read as fatal: `recovery.cancel()` and a red banner instead of a retry. Before the bound existed this case reached the 15s subscription-start timer, whose message did match a fragment, so introducing a 12s bound turned an auto-recovering pane into a dead-ended one — the #12650 shape. The same wording also fell outside `REMOTE_RUNTIME_UNREACHABLE_RE`, so the Tailscale remedy was dropped for precisely the unreachable-host failure it exists for. Keep the canonical phrase both gates already recognise rather than teaching each gate a second synonym for one condition, and pin it: the phrase is now a named constant, the corpus in `remote-runtime-transport-error-agreement.test.ts` grows the coded, hinted and code-stripped producers derived from the real helper, and a new subscribe-path test proves the connect bound (not the start timer) is what fires and that its message still classifies as recoverable once the code is gone. Verdict wording is unchanged: `unverifiable`, never a synonym for exited. Also states the bound in seconds, corrects the module comment (`handshakeTimeout` is a socket inactivity timer, so a slow-but-answering host is not cut off), and splits the subscription contract types out to stay under `max-lines`. * fix(relay): drop the duplicate connect bound on the control socket The claim that `connectDeadlineMs` cannot see a black-holed connect is false. `RelayControlClient.connect()` constructs the socket and arms `connectTimer` in the same synchronous call — `new WebSocket()` never blocks — and `expireConnect` fires from `opening` as well as `proving`. The class deadline was already a strict superset of a transport `handshakeTimeout` on that socket. It was also inert. Production passes neither option, so the transport bound was derived from `connectDeadlineMs` and both timers were 15_000, armed in the same tick; the ws timer is an inactivity timer armed on the later `socket` event, so it could not win. Its only reachable effect was changing which string a stalled relay connect rejects with, and it narrowed an existing test's 20ms deadline into a handshake bound it could race. So this removes the factory, the test-only `handshakeTimeoutMs` option and the source-grep test whose premise was wrong, and replaces them with a test that holds the real ground: a connect whose upgrade is never answered expires on the class deadline. Moving the timer arm after `open`, or narrowing `expireConnect` to `proving`, both turn it red — which is what a future reader needs before concluding the phase is uncovered and adding a second bound again. No behaviour change for a reachable relay, and none for the verdict: a stalled connect still rejects and still reaches `unverifiable`, never `exited`. * fix(runtime): stop the endpoint in the failure message from undoing the fix Putting the endpoint into the message created three problems the message itself caused. The Tailscale hint is idempotent by testing whether "tailscale" already appears anywhere in the message. That held while the message was fixed copy. Now a host called `tailscale-box` puts the word there itself, and the hint — the only actionable remedy on an unreachable host — is suppressed for it. Key the guard on the two hints instead of the word. The endpoint comes from a pasted pairing code, which is only length-capped; `normalizePairingUrl` rejects userinfo but nothing re-validates a stored offer. Render scheme, host and port only, so a pasted `wss://user:secret@host` cannot reach a surface the user reads. And drop the elapsed time from the wording. `handshakeTimeout` is a socket inactivity timer, so a `wss://` host that completes TCP and then goes silent re-arms it once and fails at about twice the bound; measured at 2008ms against a 1000ms bound. "within 12s" would have been wrong there, and the endpoint is the actionable part regardless. Also refuse a non-positive or non-finite bound: `ws` and `net` both gate on a truthy timeout, so `0` left the connect completely unbounded while still satisfying the connect-bound ratchet. * fix(runtime): keep the endpoint from smuggling a verdict into the message `isRemoteTerminalGoneMessage` in the pty transport substring-matches `terminal_gone` / `terminal_exited` / `no_connected_pty`, and it runs before the recoverable-connection gate: a match retires the pane's terminal id and cancels recovery. WHATWG URL accepts `_` in a special-scheme host, so once the failure message carried the endpoint, `ws://terminal_gone.example:6768` turned loss of contact into a terminal-gone verdict — the one conclusion `docs/reference/ssh-execution-boundary.md` forbids. Render the host only when it matches a hostname or IP-literal grammar that cannot carry such a token, and fall back to naming no endpoint at all. A well-formed host, including a bracketed IPv6 literal, is still shown. * docs(runtime): say why this connect bound is not the relay's removed duplicate
…y denial (#20047) * docs(windows): record the measured MSYS job-breakaway mechanism The per-PTY job already denies JOB_OBJECT_LIMIT_BREAKAWAY_OK for Cygwin/MSYS shells (#19068), but nothing records why, and a conpty.node built before that commit fails windows-msys-job.win32.test.ts in a way that reads as a source defect. Measured on a real Windows 11 host: both the plain and the exec- replacement Git Bash shapes leak, the escape is the MSYS runtime's own spawn/exec (fork keeps membership), and a single-variable A/B on usesCygwinRuntime flips the result 0/2 -> 4/4. Also names the gap the failure hid behind: node-pty-job-ownership.cjs asserts symbol presence, which cannot distinguish patch revisions. * fix(windows): reject a node-pty addon that predates the MSYS breakaway denial The native-runtime gate asserted only that terminateJob, listJobProcessIds and assignCurrentProcessToJob were exported. All three predate the Cygwin/MSYS breakaway denial, so an addon built before it passes every gate, isPtyJobOwnershipAvailable() returns true, and windows-pty-job.win32.test.ts passes 6/6 -- while every Git Bash child is created outside its pane's job and survives terminatePtyJob. Read the resolved .node and require the wide msys-2.0.dll literal that usesCygwinRuntime holds, the way stagedRelayAddonIsUnpatched() already tells a patched windows-process-tree addon from a published one. An addon the caller cannot name is refused rather than skipped: a gate that cannot see its subject is not a gate. Verified against real binaries on a Windows 11 host: the shared checkout's pre-#19068 build errors, a build from current patched source passes, a missing path errors. Also closes the cross-host packaging skip. The export half has to load the addon so it cannot run when the packaging host is not the target, which is how a Windows release built elsewhere could ship this. The marker is a file read and needs neither; an unrecognised layout warns rather than fails a release that was packaging fine. * fix(windows): check the MSYS breakaway denial on the rebuild path too The Electron probe carried the marker check, but it lives inside probeElectronNativeModules, which returns early whenever the Electron package binary is unusable. Covered by another path is not this path checks -- and the defect this whole change closes was a gate that looked like it checked. Reading the binary needs neither a loadable Electron nor an executable target arch, so assert it after the rebuild, beside the windows-process-tree assertion that exists for the same reason: this is the addon copied into the packaged app. Absent warns (a cross-platform rebuild need not leave a win32 addon on this disk); present and unmarked is fatal. The fixtures now write a real addon file, because the gate reads the binary it was told about rather than trusting the exports. Verified against the two real binaries measured on the Windows host: the pre-#19068 build fails this path, the build from current patched source passes. * fix(windows): check the marker on every ConPTY path the packaged app can load The packaged marker check read one hard-coded path, `build/Release/conpty.node`, and warned when it was absent. `loadNativeModule` tries `build/Release`, then `build/Debug`, then `prebuilds/win32-<arch>`, swallowing each failure, and `prunePackagedNodePty` drops the published prebuild only when a same-arch `build/Release` exists to replace it. So the two packages the check was added for were the two it could not see: - cross-host: no host but Windows can build conpty.node, so there is no `build/Release` and the prebuild is what ships. The check warned and returned. - cross-arch: `build/Release` is the packaging host's own arch, patched and marked, so the check printed OK -- while the target app cannot load it and falls through to the unmarked prebuild underneath. Measured, not assumed: both published Windows prebuilds in the node-pty tarball contain neither `msys-2.0.dll` nor `cygwin1.dll` in any encoding. They are the binary that leaks every MSYS pane child out of its job. It now sweeps every candidate present for the *target* arch and refuses a package with no candidate at all, which is a package with no ConPTY backend rather than a layout to shrug at. It runs for every Windows slice instead of only the branch the export check skips, so deleting the export check cannot silently take it too. A stale source build keeps the rebuild advice; the prebuild gets the advice that actually works, which is to package the slice on a Windows host of that arch. Also: the marker constant was re-typed in four places and was tied to the C++ literal that produces it by nothing at all, so editing the patch would have left a gate that fails every correctly rebuilt addon and tells the developer to do the one thing that cannot help. The fixtures now take the constant from the gate, and a test asserts the patch still adds `L"msys-2.0.dll"` to conpty.cc. And the rebuild path treated a missing addon as a warning even on the host that will run the install, where node-pty would fall through to that same prebuild. The verdict is now a value, so it is tested without a platform gate. * fix(windows): resolve the packaged ConPTY the way its loader does Sweeping every candidate and demanding the marker on all of them was wrong in the one case it was meant to make safe. `beforeBuild` runs `rebuild-native-deps.mjs --platform=win32 --arch=<target>`, so a cross-arch slice normally does get a patched `build/Release` for the target; `prunePackagedNodePty` keeps the prebuild anyway because its guard is `electronArch === process.arch` rather than the arch of the binary. That package is correct and its leftover prebuild is never reached, and the sweep failed it -- telling whoever ran it to package on a Windows arm64 host, which is both the wrong remedy and one no runner here can offer. Presence cannot separate that package from the one whose cross-arch rebuild quietly emitted the host's architecture, because the only difference is the arch of `build/Release`. So the gate now resolves the addon the way `loadNativeModule` does -- first candidate whose PE `IMAGE_FILE_HEADER.Machine` matches the target, walking root-then-lib for each layout in node-pty's own order -- and checks the marker on the one that will actually run. A package with no candidate, or none of the target's architecture, is refused: it has no ConPTY backend either way, and the second is exactly what a silently host-arch cross-build looks like. The PE machine reader already existed, privately, in the relay addon builder that needed the same "a cross-build cannot silently emit host arch" guarantee. It is now shared rather than copied. Two seams were unreachable from anything but Windows, so nothing tested them: - the afterPack hook's win32 block was an inline if/else that only a source-text assertion could inspect, and that assertion could not tell the difference between the check running and the check being wrapped in `try {} catch {}`. It is now `verifyPackagedWindowsNodePty`, and "the marker check runs even where the export check cannot" is four spied assertions instead of a string match. - the rebuild path's verdict read `process` directly, so the branch that fires only on the host being rebuilt for was dead on every other host. It now takes the host as arguments, and the fs checks, the warning and the failure are all exercised from macOS. Fixtures write a real PE header rather than `MZ fake addon`, since the gate now reads one. The machine table is pinned to the documented IMAGE_FILE_MACHINE values, because every fixture builds its header from that table and a table wrong in both entries would otherwise agree with itself. * fix(windows): say why the packaged ConPTY fell back, not just that it did The previous commit resolved the addon by architecture but still had one message for every way the resolution could land on the published prebuild. Those ways want opposite remedies, and the one it printed was the remedy the commit before it had just called wrong: - no source build in the package at all — the slice has to be built somewhere that can build node-pty for the target arch. - a source build that is there but is the packaging host's architecture, because the cross-arch rebuild did not honour `--arch` — re-running that rebuild is the fix, and "package on a Windows arm64 host" is neither necessary nor possible. The second is the common one, since node-pty publishes a prebuild for both Windows arches and prune keeps the target's on every cross-arch package. So the old text fired mostly on the case it described least. It now reports which source builds were skipped and the machine field each carried, and names the rebuild command. "Nothing the target can load" had the same problem in reverse: a zero-length or truncated `conpty.node` got a cross-architecture diagnosis. Every candidate is now named with what was actually read, including "not a PE image". The rebuild path asserts the architecture too. A rebuild that ignored `--arch` was otherwise only visible at packaging, two steps from the command that fixes it. Arches with no known machine value are left unjudged rather than guessed at. Two things the extraction broke or nearly broke, both found by mutation: - the shared PE reader answers `null` where the relay builder's private copy returned a number, which would have turned its "node-gyp ignored --arch" error into a `TypeError`. Both callers now go through `describePeMachine`. - the rebuild fixtures stage a script's co-located modules by walking its imports, and the walker only understood `from '...'` — so the gate's new `require('./windows-pe-machine.cjs')` was left behind and every subprocess test failed with a resolution error, which is the exact failure its own comment warns about. It now follows `require` and bare side-effect `import` as well, and has tests; the fixture stages the gate by walking it rather than by naming one file. Fixtures write real PE headers through one shared builder instead of three hand-rolled ones. * fix(windows): run the node-pty addon gates on the Windows job that can `rebuild-native-deps-node-pty.test.mjs` carries four `skipIf(platform !== 'win32')` tests. The full suite runs on ubuntu, and the Windows PR job runs an explicit file list that never named this file -- so those tests were skipped on Linux and never reached anywhere else. Three of them predate this branch. The Windows job is added the four node-pty addon suites plus the module-walker one; the comment above that list already says why it is the right place, which is that the addon assertions only hold once natives have been rebuilt. Running the path-joining suites there also covers the separator this gate's candidate list is built from. The rest is round-three review: - the rebuild-time arch assertion told a reader "node-gyp did not honour --arch" about a file that was not a PE image at all, which is a truncated or quarantined artifact and a different command to run. The two now read differently, and neither claims the other's cause. Same fix the packaged gate had one commit ago, in the place that had not had it yet. - the missing-addon error said node-pty "would load" a prebuild without checking it is there. It says "fall through to" now, which is true either way. - `isLoadableByArch` had no caller left once the packaged gate started needing the raw machine field for its message. Removed rather than kept warm. - each candidate's header is read once instead of up to three times. - the module walker's comment claimed every shape that reaches a co-located module; it does not follow `projectRequire`/`requireLocal`, and it must not -- those specifiers resolve against the project root, so following one stages the wrong path and the copy fails. Proven by trying: widening the pattern to require-shaped names broke nine tests on `projectRequire('./config/scripts/...')`. The comment now says what it follows and why it stops there. - a new test resolved a file URL with `.pathname`, which keeps the drive-letter slash on Windows -- the very job this commit adds it to. * docs(windows): put the superseded export-only gate in the past tense It describes what used to pass a broken addon, so present tense reads as a description of the gate the same document then explains replacing it. * fix(windows): repair what running the node-pty suites on Windows exposed Putting these files on the Windows job turned four assertions red on the first run. Three of them were in tests that carried `skipIf(platform !== 'win32')` and had therefore never executed anywhere, on any branch. - `writeFakeElectronRebuild` emitted the `windows-process-tree` addon a real rebuild leaves but never node-pty's, so every Windows test of the rebuild path ran against a tree no real rebuild can produce: node-pty "rebuilt" with nothing in `build/Release`. The new same-host check reads that state correctly and said so. The fake rebuild now writes `build/Release/conpty.node` when it was asked to rebuild node-pty for win32, with the marker and the target machine. - `mkTempProject` never staged `windows-process-tree-creation-time.cjs`. The rebuild script reaches it through `projectRequire`, which resolves against the project root, so the module walker cannot follow it and must not try. Staged by name, with a comment saying which of the two it is. Without it the windows-process-tree probe failed to load its own checker and the module joined `modulesToRebuild`, which is the second and third red assertion. - the two `nodePtyAddonPath` cases compared against a literal POSIX string. `resolve` returns a drive letter and backslashes on Windows, so they could only ever pass off it. Built from segments now, which still pins the `..` traversal that is the point of the test. Verified on macOS: ensure-native-runtime-job-ownership, verify-packaged-node-pty-job-ownership, windows-pe-machine, script-module-dependencies, rebuild-native-deps-node-pty, rebuild-native-deps, rebuild-native-deps-windows-process-tree, ensure-native-runtime -- 109 passed, 6 skipped. The 6 are the Windows-gated rebuild tests, which is the job this change is aimed at; Windows CI is the arbiter. * fix(windows): give the packaged fallback a third verdict, for a file that is no image The packaged gate had two remedies for landing on the published prebuild and picked between them on `!prebuilt`, which puts a truncated, empty or quarantined `build/Release/conpty.node` in the cross-arch bucket: "the source build beside it is the wrong architecture ... re-run with --arch". It is not the wrong architecture, it is not an architecture, and `--arch` is not the command. The rebuild-path gate was split for exactly this a commit ago; this is the same split in the place that had not had it. Also from review of the settled state: - the stale-source-build branch ended in a call that happened to throw, so a reader could not see it was terminal and the file was read twice to get there. The verdict is now an Error the caller throws, built once from the read it already did, and shared with `assertCygwinBreakawayDenied` rather than copied. - four injection seams had no consumer in production or in tests (`deniesBreakaway`, `peMachine`, and `exists`/`peMachine` on the rebuild verdict). An unused seam is a way for the tested path and the real one to drift apart; the tests drive both with real files. Removed. - the loader table existed in a docblock and in the reference doc, already disagreeing about row four. The docblock cites the doc now. - `peImage` stamped machine `0x0000` for an arch it had no value for, because `writeUInt16LE(undefined)` coerces to zero. A fixture that quietly invents the field the gates read is the same species of silent lie the gates exist to catch; it throws, and a test holds it to that. - a test named for refusing an unreadable candidate asserted only that something threw. Renamed to what it proves. * fix(windows): make the rebuild fixtures represent a tree that can exist Second round of what running these suites on Windows exposed. The module the walker could not stage is now staged, so the probe reached its own checker and the real reasons surfaced: - `writeFakeWindowsProcessTree` exported `{}`. The creation-time gate reads `supportedProcessDataFlags` off the addon and calls its absence "the tarball prebuilt, not a build of the patched source" — correctly. The fixture predates that gate and, being Windows-only, never met it. The healthy fake now reports the flag, taken from the gate's own constant. Two tests were failing on this, the second only because the module then joined `modulesToRebuild`. - `rebuilds a loadable ConPTY native that lacks Orca job ownership` asked for a node-pty rebuild in a tree where node-pty had none of the payload its package ships. It gets `writeFakeNodePtyConptyPayload` like its two siblings. I also tried making the fake rebuild emit `build/Release/conpty.node` the way a real one does, and backed it out: `restoreNodePtyWindowsConptyRuntime` keys off that file and then reads `third_party/conpty`, so emitting it in a tree without the package payload turns one honest gap into an ENOENT two steps away. The payload fixture is where "node-pty has its addon" belongs. macOS: ensure-native-runtime-job-ownership, verify-packaged-node-pty-job-ownership, windows-pe-machine, script-module-dependencies, rebuild-native-deps-node-pty, rebuild-native-deps, rebuild-native-deps-windows-process-tree, ensure-native-runtime — 112 passed, 6 skipped. The 6 are the Windows-gated rebuild tests; Windows CI is the arbiter and is why they are on that job now. * fix(windows): register the node-pty addon suites in the scope list too Putting the five suites in the Windows lane's vitest argv gets them run once the job starts; `WINDOWS_PACKAGE_TESTS` in `pr-code-change-scope.mjs` is what decides whether the job starts at all. Only the argv was updated, so a PR touching just `rebuild-native-deps-node-pty.test.mjs` would not have started the Windows job, and its four Windows-only cases — including the same-host-absent one added here — would have run on no machine for that PR. Exactly the shape of gap this branch is about. Both lists now name all five, and `windows-pe-machine`, `windows-pe-image-fixture` and `script-module-dependencies` join `NATIVE_RUNTIME_PREFIXES` so a change to the modules themselves starts it too. `win32-test-lane-registration.test.mjs` exists to catch precisely this and did not, because its matcher only recognises suite-level gates (`describe.runIf` / `describe.skipIf`) and a `.win32.` filename. These tests gate per `it`. Widening it is not this branch's change to make: about thirty files across the repo carry per-`it` Windows gates and are unregistered, so the ratchet would move far beyond node-pty. Flagged rather than done. Message repairs from the same review: - the non-PE arm of the rebuild-time arch error read "... is not a PE image, so nothing can load it, so node-pty would fall back ...". The shared consequence clause already opens with ", so". - the no-source-build packaging error ended "Package this Windows slice on such a host", which is wrong advice for the case where the host IS such a host and the rebuild simply left nothing — reachable when the artifact is removed before prune runs. It now names both readings and points at the beforeBuild output. - the relay-addon builder blamed `--arch` for a build output that is not a PE at all, the same guess the node-pty gate was taught to stop making. - the patch-drift assertion was a bare `toBe(true)`, so a real drift read as "expected false to be true". It now names the two things that can have drifted and what happens until they agree.
#19882) * test(repro): #19735 resumes a published mirrored pane before its handle lands * fix(runtime): park a mirrored pane's resume until its PTY handle lands Mirror hydration means the host's tab rows arrived, not that a given pane's liveness is decidable: the PTY handle lands one relay round trip later. On that frame the pane read as not-live and the sweep resumed a session the host was still running, producing a duplicate resume tab. An empty handle map for a published row is unverifiable, never exited. Park the pane on a per-pane wait with three bounded exits, each replaying the sweep: its own handle lands, the row is retracted, or a deadline expires. The deadline decides resume rather than an indefinite hold, and is scoped to the connection generation so a reconnect re-arms it. Closes #19735 * fix(runtime): bound the handle-gap expiry map to the current connection * fix(runtime): void a handle-gap verdict the reconnect made stale The per-pane park bounds itself with one deadline per connection, but the waiter never recorded WHICH connection it was armed on. A wait armed on generation 0 that fires after a reconnect stamps its expiry against the current generation, so hasHostMirrorHandleWaitExpired agrees, the mirror lookup returns null, and the pane is resumed after 1ms on a connection that has had no chance to publish the handle. That is #19735's fork with an extra step, reached through the guard that exists to prevent it. The module's own doc comment claims the opposite -- "a reconnect bumps the connection generation and arms a fresh wait" -- and that is true only for a wait which had ALREADY expired, which is precisely the case the existing test covered. The test and the comment agreed with each other and both were wrong about the live case. The waiter now carries the generation it was armed on and records no verdict when the generation has moved; the replay re-parks through the existing machinery and the new connection gets its own full budget. Still bounded per connection generation, which is what was documented all along. Also pins the three sibling attacks on the same window: two panes in one environment where only one handle lands, a handle published by a foreign environment, and an environment tearing its rows down mid-park (which leaves no waiter and no scheduled timer). The test file now leads with how to assert on this module at all, because the obvious shape cannot fail. "Did the waiter release" is not an observable here -- a waiter released for the wrong reason is re-parked by the replayed sweep, so the store reads identically one tick later, and a mutation releasing every waiter on any tab's handle survived twelve assertions written that way. What a spurious release costs is the deadline, so the assertions advance the clock and require the pane to decide on the ORIGINAL schedule. * fix(terminal): a live pane owns its transcript in any workspace The resume dedup was scoped to the record's own workspace on both terms -- the entry's tab had to be in worktreeTabIds AND entry.worktreeId had to match -- and additionally required entry.state !== 'done'. A record whose peer pane has finished a turn and still holds a live PTY therefore matched nothing, and the sweep launched a second agent onto a transcript the peer is still writing. Cross-workspace, it matched nothing even while the peer was mid-turn. The two ids really do drift. canonicalizeTerminalSessionWorktreeId re-keys tabsByWorktree, tabGroups, tabGroupLayouts, activeTabIdByWorktree and activeGroupIdByWorktree onto the canonical worktree id, and does NOT re-key sleepingAgentSessionsByPaneKey, whose records carry worktreeId inside them. So adopting an orphaned terminal is a direct producer of a record naming one workspace while its pane and status row name another. Split into two arms rather than widening the existing condition. The new arm carries no workspace scope but demands hard evidence: a provider session id names one transcript, so a pane whose exact PTY is live right now already owns it wherever that pane sits, and no workspace boundary makes a live PTY less live. The scoped arm keeps its scope and its state !== 'done' term, because a status row with no live PTY is a claim about the past and must not reach across workspaces. Relationship to #19736: that PR fixes the SAME-workspace half of this in the same function, by relaxing only the status term. This arm covers that cell too -- measured both ways on this branch, which does not carry #19736: its thirty `checks exact live ownership before resuming` cases all pass with this change alone, and ten of them fail without it. So this supersedes #19736 rather than sitting beside it, and #19736's one-line `export` of stablePaneHasLivePty is carried here because this arm needs it. If #19736 lands first this becomes a pure widening and its tests should be kept. Both cells are pinned here either way. * fix(runtime): isolate one pane's replay from the handle-gap drain One store write releases every due pane, and the drain runs synchronously inside a zustand subscriber. `waiter.run()` was unguarded, so a single pane's replay reached two things it has no business touching: - the throw escapes out of `useAppStore.setState`, meaning the mirror apply that published the PTY handle throws at its own call site; - every pane queued behind the thrower is stranded — waiter still parked, deadline still armed — and then decides on a connection whose evidence landed long ago. The deadline path fans out the same way, so a throwing replay also escaped the timer callback. Reachable: `resumeSleepingAgentSessionsForWorktree` reaches `state.createTab` with no guard of its own. The panes in a drain are strangers to each other and to the frame that released them; none of them should be able to see another's failure. The new tests live in their own file because host-mirror-handle-gap-resume.test.ts drives the waiter through the real resume sweep and so cannot choose what a replay DOES. Note for anyone extending that file: per its header, "did the waiter release" is not an observable here — a spurious release is re-parked immediately and reads identically one tick later. These tests assert on timer count and on the deadline instead. Also records two findings next to the code, so they are not rediscovered: `expiredGenerationByPane` is never pruned for a removed environment (bounded and inert, since removal advances the generation, but it does not drain — and a DIFFERENT leak in that same map is being fixed concurrently, so reconcile rather than patch around it); and sustained reconnect churn holding a pane parked indefinitely is CORRECT, not the latch-that-never-releases defect, because under churn liveness genuinely is unverifiable and ssh-execution-boundary.md forbids resolving that to `exited`. It has the shape of the defect and will eventually be "fixed" by someone who does not know that. Mutation: dropping the guard kills exactly the three new assertions and leaves all twelve existing waiter tests passing. * fix(runtime): drain a removed environment's handle-gap verdicts on teardown `expiredGenerationByPane` is pruned only by rules that run when a verdict is RECORDED — the stale-generation sweep here, and the tab-death sweep added separately (8f16641, env-scoped in c0e4423). An environment that is REMOVED records nothing ever again, so neither rule can reach its rows and they survive for the life of the session. Two orphan classes on one map; neither prune subsumes the other, because both are driven by a recording. Severity is a leak, not a correctness bug, and the commit pins WHY so nobody re-derives it: removing an environment advances its connection generation, so a stranded verdict can never match again even if the id returns. That test exists to stop the generation advance being "optimised" away later, since it is the only thing making the stranded row inert. Hung off `clearWebSessionTabsTrackingForEnvironment` because that is the only caller that fires for an environment that is going away. Clears VERDICTS ONLY. Parked waiters deliberately survive, matching `clearHostSessionMirrorHydration`: a re-pair or effect restart replaces the connection's evidence, it does not cancel the recovery this client still owes the pane. A waiter left behind is bounded by its own deadline and replays its sweep exactly as it would have. Clearing them here would silently drop a parked resume that nothing else replays. A measurement worth recording, because it argued me out of a change I was about to make: on the unfixed map the per-expiry rescan is super-linear — 500/1000/ 2000/4000 sequential expiries cost 7.2/15.3/51.8/173.1 ms, doubling ratios converging on ~3.35 against 4.0 for quadratic. That looked like a case for reshaping the map to `Map<env, {generation, Set<tabId>}>`. It is not: the quadratic is a property of the LEAK, not of the scan. Once the tab-death prune holds the map at roughly one entry per environment the scan is over ~1 entry, and a counting probe on the fixed map (summing `map.size` across N expiries, which IS the iteration count and needs no clock) gives exactly N-1 — linear, and 2000x fewer iterations than quadratic at N=4000. The flat prefix loop used here is the established pattern in this subsystem and needs no restructure. Two methodology traps this cost, recorded for the next person measuring in this repo: `vi.useFakeTimers()` fakes `process.hrtime` and `performance.now` as well, so a timing harness reports the advanced deadline rather than work done — fake only the timer surface under test. And expiring N panes in one burst measures the fake-timer harness clearing N timers, not product code; 1000 panes "cost" ~1s that way and almost none of it was ours. Mutations: a clear that drops nothing kills exactly the two assertions that claim it drains, and correctly leaves the waiter-survival and generation-advance tests passing. An UNSCOPED clear kills the same two, via their sibling- environment half. * fix(runtime): reconcile three branches' handle-gap verdict rules into one loop Three agents changed `recordExpiredWait` on three branches and each verified only their own. This is the union, resolved into the agreed shape and proved on one tree. The rules are NOT alternatives — they have different safety properties, and flattening them to one scope is wrong in both directions. Both wrong shapes were independently written before this was reconciled, so the comments say why. GENERATION rule, per key across EVERY environment (adv2-skew's class). `hasHostMirrorHandleWaitExpired` compares a row against its own environment's CURRENT generation, so a row whose generation has moved can never return true for anybody; retiring it cannot cost a reader a verdict, whoever owns it. Scoped to the recording environment, an environment that reconnects and then goes quiet strands its rows forever. TAB-DEATH rule, recording environment ONLY (my class). Row absence is transient where a generation is not: a sibling mid-republish has no rows for a frame and would lose a verdict its pane still needs — reproduced before it was narrowed. Teardown drain (adv2-races' class) is unchanged and orthogonal: it is the only trigger that fires for a REMOVED environment, whose rows no rule above reaches because such an environment records no further verdict. Right predicate, wrong trigger. The union suite proves all four orphan classes simultaneously, plus the two properties none of the three rules may break: the verdict stays sticky enough to break the park/expire/replay loop, and no rule evicts a verdict a live pane still needs. It uses three environments throughout, because with two at one generation the candidate rules are indistinguishable and the naive fix survives. THE FOURTH CLASS IS UNOWNED AND ASSERTED AS A HAZARD. A retracted tab id that is republished inherits the old pane's verdict and skips its own wait. Unlike every other gap on this map it is NOT conservative: the others drop a verdict and re-park, holding longer, while this one retains a verdict and resumes on a handle that has not landed — the #19735 direction. No rule reaches it: the tab-death predicate stops matching once the id is republished, the teardown drain fires on environment teardown rather than tab retraction, and no waiter exists to observe the retraction because a pane holding a verdict never parks. Closing it needs a fourth trigger, on row retraction. The suite pins the current behaviour so it cannot be quietly forgotten. Union finding, recorded rather than merged: adv2-skew's `docs(relay): the live-broker wait budget does not bound the call` (6b02982) is SKIPPED here. It documents the unbounded wait, and adv2-concurrency-fixes (1673716) fixed exactly that by extracting the loop into relay-live-broker-wait.ts. The doc and its test pin behaviour the union no longer has. This is the kind of interaction neither branch could see alone. * fix(test): repair the teardown suite the union broke Cherry-picked from 46ad377 with the relay half dropped: that commit also repaired relay-concurrency-policy-flip-mid-mint.test.ts, which does not exist on this PR and belongs with the relay cluster's own branch. The handle-gap half is what this PR needs. Neither break was visible on its own branch -- both only appear once the verdict rules compose. * fix(runtime): a handle-gap verdict answers for its pane, not for the tab id Folds adv2-skew's e8cac05 into the reconciled union. Closes the fourth orphan class, the only one that was not conservative: a retracted tab id republished as a different pane inherited the old pane's verdict and skipped its own wait — the #19735 direction rather than a longer hold. It needs no fourth trigger, which is why it composes with the three drains rather than competing with them. Every trigger those rules own fires downstream of the moment this hazard needs. The verdict instead carries the environment-minted PTY binding its pane held AT PARK TIME, and only answers for a pane that still holds it: a republished pane binds a newly minted PTY and serves its own wait, while a genuine reattach to the same PTY inherits, which is correct — the verdict follows the PTY, not the id. A transient rowless frame touches neither, so the read-time check is safe where a retraction-triggered prune would not have been. TWO MEASUREMENTS, both requested rather than assumed. 1. The record-then-release ordering is load-bearing and IS pinned. `recordExpiredWait` reads the waiter's park-time binding, so it must run before `releaseWaiter` deletes the entry. Swapping the two statements fails three cases, so the capture is not correct merely by accident of statement order. 2. The `''` fallback is a MATCH VALUE, not a null: two panes that both hold no environment-minted PTY compare equal and inherit, which is the same hazard in a narrower window. Measured unreachable through the production park path rather than assumed — the only route in is `kind: 'handle'`, which `findUnhydratedHostMirrorForPane` reports only when `tabHoldsEnvironmentPtyBinding` finds a binding, reading the SAME map through the SAME predicate as `paneBindingFor`. It now refuses to answer anyway. That coupling is two functions in two files with nothing enforcing it, refusing costs only a re-park, and the direction is conservative. THE REFUSAL IS WHAT FOUND THE REAL BUG. With `''` matching, any fixture that omits `terminalLayoutsByTabId` records `''`, compares `'' === ''`, and passes while the pane-identity check is entirely inert. Making it refuse turned that silence into four failures across host-mirror-handle-gap-drain and -teardown, whose fixtures seed no layout binding at all. Both now bind per environment — one shared environment id filters every other environment's pane back to `''` and restores the no-op. Mutation-tested on the merged tree: ignoring the binding fails case D and the mid-wait case; re-reading at expiry fails the mid-wait case and nothing else; letting `''` match fails the empty-binding case; widening the tab-death rule across environments still fails the live-verdict case, so pane identity does not weaken the scoping the sweep was reconciled around. Also fixes a real-clock race this branch introduced: the revoke-window test read `Date.now()` separately from `enqueue`'s own stamp, and under load the drift ate into the window. It now anchors the injected clock to the item's `createdAt`. * docs(runtime): the two guards on the park-time binding are not redundant Recording a reconciliation result that existed only in a review thread, and correcting it in the process — measuring the claim changed it. The claim under review was that the `?? ''` fallback in `recordExpiredWait` is unreachable by two independent guards, either sufficient alone: the caller's generation gate (a missing waiter fails `undefined === number`) and the record-before-release ordering. That is not what the code does. Measured, by removing each in turn: - ordering removed, generation gate kept: the gate does NOT carry it. With the waiter already deleted, the gate is false on every expiry, so nothing is ever recorded — five failures, and the door is shut by breaking the mechanism rather than by refusing ''. - generation gate removed, ordering kept: 736 files green, one failure, and it is `does not let a wait armed on the previous connection decide the new one` in host-mirror-handle-gap-resume.test.ts — a different property entirely. So the ordering alone makes `''` unreachable, and the generation gate is not a second guard on it at all: it pins reconnect-void. Both are load-bearing, for different reasons, which is a stronger argument against removing either than redundancy would have been — redundancy invites deleting one. Worth writing in the file because the two sit three lines apart and read as belt and braces on the same thing. The `''` comment next to them already exists because an unexplained guard on an unreachable value gets deleted as dead code in a year; a guard that looks redundant is deleted sooner. No behaviour change. One comment, corrected against measurement rather than against the thread it came from. * fix(runtime): a published handle retires the verdict it answered The fourth eviction trigger on `expiredGenerationByPane`, and the reason it is not redundant with the three already there or with the two other agents' guards on this same map. A verdict records that a pane's 15s handle-gap wait ran out. Nothing retires it when that pane subsequently publishes its handle, so the NEXT gap on that pane gets no wait at all — #19735 with the bounded wait removed rather than merely shortened. Measured on the reconciled union tree (1b621b6) plus the outage guard: the verdict still answered `true` after the handle landed, and the second gap resumed with zero panes parked. Why none of the existing rules reach it, each checked rather than assumed: - superseded generation: #19647 in this same stack stops recording `status: null` for an unreachable host, so the generation no longer moves across an outage on one runtime. - dead tab row: the row stays published throughout. It is the HANDLE that comes and goes — that is the definition of the gap. - removed environment: the environment is still here. - read-time pane identity (adv2-skew, cdafc90): the pane keeps the same layout binding across the gap BY DESIGN, and the union suite pins that a genuine reattach to the same PTY must inherit. That check discriminates a different pane behind one tab id; this one discriminates a later gap on the same pane. - contact lost (adv3-journeys, 2da6624): no outage is involved; this is a healthy connection where the host was simply slow once. Composition proven by mutation on the union tree, four disjoint kills: dropping this drain kills 2 tests and only mine; dropping the re-park worktree kills 1 and only mine; dropping the contact guard kills 1 and only theirs; forcing the contact guard always-true kills 16 across every suite. No mutation kills another agent's test, so these are three guards on three holes, not three on one. Also carries `worktreeId` across a re-park: adopting an orphaned terminal re-keys `tabsByWorktree` without re-keying the record, so a live wait kept releasing on retraction evidence about the workspace it was no longer about. The park-time `paneBinding` deliberately does not move with it — that is the pane's identity, this is only where its rows are filed. * docs(runtime): the reused-tab-id class is closed at read time, not still open The `ExpiredHandleGapVerdict` docstring told the next reader that a retracted tab id republished under the same id still inherits its predecessor's verdict, and that closing it "needs a fourth trigger, on row retraction". The test it names as its own pin says the opposite: class D in host-mirror-handle-gap-verdict-union.test.ts asserts the verdict does not answer, and explains it is closed at READ time rather than by any prune. Provenance, since two sources disagreeing is what made this expensive: the paragraph was last written in 46ad377 and the read-time pane-identity check landed one commit later in cdafc90 (adv2-skew). The prose predates its own fix by a single commit and was never updated. Confirmed by mutation rather than by reading: dropping `verdict.paneBinding === paneBindingFor(...)` fails exactly "handles all four orphan classes simultaneously", which is the class-D assertion, so the read-time check is what closes it. Rewritten to say what the code does, keeping the part that was always true — why no trigger could have reached that class — and keeping the distinction the new PUBLISHED HANDLE drain needs: read-time identity separates two panes behind one tab id, the drain separates two gaps on one pane. The drain does not close class D and must not be read as closing it. Also records why this block specifically keeps going stale: several agents change this map in parallel, the invariants move faster than the prose, and when the two disagree the test file is the one that ran. * test(runtime): pin replay containment on the deadline path too Cherry-picked from aa98edf (nwparker/adv3-failure-fixes) with its implementation hunk dropped: a second agent found the same throwing-replay hole independently, and `15f34014153` already closed it on this branch with an equivalent guard. Applying both would have been a double-apply, and the two spellings of the log line would have shipped side by side. The tests are worth keeping regardless. The first duplicates coverage 15f3401 already has; the second does not -- it drives the throw from the DEADLINE path rather than the store-write path, which is a separate call into releaseWaiter and was unpinned. Spies retargeted from console.error to console.warn, the channel the guard that actually shipped writes to, so the suite silences what the code emits. * fix(runtime): isolate one worktree's replay from the mirror-hydration drain The same fan-out hazard as the handle-gap drain, one module up. Settling an environment drains every worktree parked on it in a single loop, called from the frame apply, with `waiter.run()` unguarded. One replay that throws strands every waiter queued behind it and surfaces in the caller applying the frame. Found by looking for the sibling of a defect rather than by a separate interleaving: both modules park a `run` callback and drain N of them from one event, so both have the same blast radius. Kept as its own commit because the two modules route independently. Mutation: dropping the guard kills exactly the one new assertion. * test(runtime): pin sleeping-agent resume on a failed SSH target The terminal-state floor in workspace-terminal-host-authority.ts has three consumers: initial-terminal seeding, the startup terminal watcher, and sleeping-agent resume. Seeding is covered end to end by worktree-agent-activation-seam.test.ts. Resume was covered only at the predicate, so nothing failed if the floor stopped reaching it — and the floor's own comment says the cost of losing it is a failed target left terminal-less with unresumable agents for the rest of the app session. Pins the resume half directly: an SSH git worktree on a target whose sync terminated in offline/error with an empty hydrated set resumes its sleeping agent. Two controls keep the floor from widening into "resume whenever we are unsure" — an in-flight 'pulling' sync and no sync status at all both stay unverifiable and resume nothing. Verified by mutation: emptying TERMINATED_WITHOUT_ANSWER_PHASES fails exactly the two floor assertions and leaves both controls passing. Routes independently of the two fixes on this branch: the floor predates this stack (#16750), and this only closes a coverage gap in it. * test(runtime): pin the store subscription the reconciled loop can leak The retention suite that `reconcile three branches' handle-gap verdict rules into one loop` replaced carried an assertion the split suites did not: the store subscription is held for exactly as long as something needs it. Measured before writing it, because half of it was already covered: RETAIN direction -- drop the verdict term from `stopStoreSubscriptionIfIdle` so a verdict with no waiter behind it loses the subscription its drain needs: already caught, 2 failures in host-mirror-handle-gap-landed-handle.test.ts. RELEASE direction -- never release the subscription at all: caught by NOTHING. That mutation passes all 272 tests across the 33 other handle-gap and session-tabs suites. A leaked subscription rescans every parked pane on every store write for the life of the session and nothing notices. So this is for the release direction. The retain cases ride along because both halves of one invariant belong in one file, not because they were missing. That term is also precisely what the reconcile moved -- it now counts verdicts as well as waiters -- so it is the part of this map most likely to drift again. Asserted with a spy on useAppStore.subscribe rather than a new test-only export: whether the module is subscribed is already observable at the store boundary, and the production surface should not grow just to say so. * fix(lint): carry SAFETY rationales for the handle-gap fixtures main tightened typescript/consistent-type-assertions to assertionStyle: never after this branch was written. The gate only ran here once the rebase put the casting config at the merge base, so these sites are new to it, not new to the branch. The store seeds are genuinely partial -- dropping the casts does not typecheck -- so each carries its rationale. * fix(runtime): release a handle-gap pane once per store write `releaseDueWaiters` snapshotted the due KEYS and then re-looked-up each one. A replay earlier in the loop writes to the store — the sweep reaches `createTab` and `clearSleepingAgentSession` — and zustand notifies re-entrantly with no queue, so the nested pass can release and re-park a pane still queued in the outer loop. The outer `releaseWaiter(key)` then found the re-park, cleared its brand-new deadline and replayed it a second time off one store write, handing that pane another full budget. That is the extension `parkUntilHostMirrorHandleLands` already refuses to grant a re-park, arriving through a different door. The direction is conservative (hold longer, never resume early), which is why no outcome assertion could see it; only the replay count separates the two implementations. Snapshot the waiter alongside its key and release only while the map still holds that same waiter. Also folds host-mirror-handle-gap-replay-containment.test.ts into the drain suite, since the guard it pins is the one this commit extends. It was the same fix imported twice: its deadline case is a strict subset of the drain suite's, its store-write case differs only by also asserting that later store listeners still run, and one mutation — rethrowing from the replay catch — killed all five cases across both files. Its fixture also seeded no layout bindings and used tab ids `isWebTerminalSurfaceTabId` rejects, so those panes could not have reached the park path it claimed to exercise. The unique assertion moves across; the file goes. Killed by `releases a pane once per store write even when an earlier replay re-enters the drain`: 2 replay calls instead of 1 without the identity guard. * test(runtime): retire the handle-gap assertions that could not fail `returns to baseline under churn across all three drains` asserted nothing. It ran 300 expiries and then cleared every environment's verdicts by name before counting, so the map was empty by construction — deleting the whole prune loop in `recordExpiredWait` left the test green. It now asserts the bound BEFORE the teardown clear: 300 expiries must leave exactly one live verdict per environment. It also binds each round's pane to the environment recording it; the old fixture filed every binding under env-a, so two rounds in three stored the empty match value the read-time check refuses, and that much of the churn was synthetic. Renamed: there are four drains, not three. Two comments described outcomes their assertions do not produce. `c1` reads false on the read-time generation gate alone, whether or not a drain ever swept it — the count below is the only assertion that distinguishes retired from stranded. And the discriminator in `never evicts a live pane verdict` is env-b, whose row goes absent while env-a records; env-c is a control that holds under every candidate rule. Two more fixtures modelled states the mirror apply cannot produce, both leaning on `ptyIdsByTabId[tab]` holding a PTY id no leaf of that tab is bound to. It builds one from the other (web-session-tabs-sync/terminal-build.ts), so they can never disagree. The producible shape is a SPLIT tab whose sibling surface went `ready` first, which is what both cases now seed — and which makes the residual they were quietly standing in for visible instead: the decidability gate above this wait is tab-granular while everything below it is leaf-aware, so a sibling handle ends the wait for a surface still `pending-handle`. Recorded at the gate in host-mirrored-pane-liveness.ts, pinned by name, and left open here: it needs the per-surface status the host already publishes and the client drops on apply. Also states what the unscoped live-PTY arm trades — a finished agent whose shell is still up releases its record and will not auto-resume — because it reads as a regression and is not one. And replaces the subscription-lifetime header's unreproducible "272 tests across 33 suites" with the measured 326 across 37. * fix(runtime): re-judge a handle-gap waiter the drain's own replay moved The identity guard added one commit ago catches only half of how the drain's snapshot goes stale. It proves the map entry was not REPLACED; it cannot prove the verdict still holds, because `parkUntilHostMirrorHandleLands` re-parks a still-parked pane by MUTATING the waiter in place. `worktreeId` moves with `run` — that is what adopting an orphaned terminal does — and object identity survives it. So a waiter the snapshot judged retracted, because its tab was absent from the worktree it was filed under, can be re-filed by an earlier replay in the same loop and then released on evidence about a workspace it is no longer about. That is the defect the `existing.worktreeId` assignment exists to prevent, reached through the drain instead. Neither guard covers the other: re-judging alone still replays a re-park twice (it was just made due, so it re-judges due), and identity alone misses the mutation. Both, in that order. A waiter that is no longer due simply stays parked — bounded by its own deadline and re-judged on the next store write, so declining costs at most one frame of latency. Killed by `does not release on retraction evidence a mid-drain re-park has already made stale`; the identity half is still killed by `replays a pane once per store write even when an earlier replay re-enters the drain`. 29 mutations across these modules, no survivors. Corrects three claims made in the two preceding commits, each wrong in a way a future reader would have acted on: - the duplicate release does NOT extend the pane's budget. `releaseWaiter` deletes the waiter before calling `run`, so the re-park takes the `!existing` branch and arms a full deadline either way; a second release clears and re-arms at the same instant. What it costs is running an entire worktree resume sweep twice off one frame. The test is renamed to say so. - `retainPendingTerminalBindings` carries a `pending-handle` surface's prior binding forward, so the split-tab residual cannot arise from a bound leaf going pending — it needs a leaf that was NEVER bound, which means a cold start or re-pair with no layout to retain from. The fixture staged the impossible history; it now seeds the producible first frame, where the point sharpens: no wait is armed at all, which the test now asserts directly. - `clearSleepingAgentSession` cannot re-enter the drain; the subscription's slice guard drops that write. Only `createTab` can. Also: the churn assertion pins that the prune loop runs at all, not which rule prunes — with one live tab per round either rule alone still reads 3. Says so, and points at the case that does isolate the generation rule. * test(runtime): stage the handle-gap adoption the way a sweep can reach it The case added one commit ago pinned the right guard through a call sequence production cannot make. A replay is `resumeSleepingAgentSessionsForWorktree` closed over ONE worktree, so it re-parks only under that worktree — but the fixture had the first pane's replay re-park the second under a DIFFERENT one. That is the same fault the previous commit corrected in the resume fixture, made one file over. The reachable route: the second tab has already been re-keyed onto the canonical worktree id while its sleeping record still names the old one, so the first pane's sweep legitimately owns it and re-parks it there, mutating the live waiter in place. The waiter's snapshot verdict — "retracted", because the tab is absent from the id it was filed under — is stale by the time the loop reaches it. Restaged that way, with both waiters and the re-park inside one worktree. Also drops the second global store read. Guard TWO now re-judges against the same `state` the drain was notified with, because the two provably cannot differ here: a re-park only happens when `findUnhydratedHostMirrorForPane` finds the row already filed under the sweeping worktree, which is a row this frame carries. `useAppStore.getState()` was an unpinnable degree of freedom — swapping it for `state` left every test green — and it contradicted the drain's own claim to judge one snapshot. And corrects two more claims: guard ordering is a cost preference, not a correctness requirement (either order works; identity first is just cheaper), and "no wait is armed here" in the split-tab fixture is not caused by the cold start — it is the tab-granular gate reading the sibling's handle, which is the residual itself. 29 mutations across these modules, no survivors. Each guard is killed by exactly one case and they do not overlap: `replays a pane once per store write even when an earlier replay re-enters the drain` for identity, `does not release on retraction evidence a mid-drain adoption has already made stale` for re-judging. * fix(runtime): judge a re-parked handle-gap waiter on the live store Reverts the `state` read the previous commit put in guard TWO, and says why the difference is deliberately unpinnable rather than leaving the next reader to "simplify" it back. The previous commit swapped `useAppStore.getState()` for the subscriber's `state` because a reviewer noted the swap left every test green. That was optimising for mutation-killability over the property the module exists to protect. The two reads do agree on every sequence the sweep can produce — a replay's only store write is `createTab`, which appends a freshly minted tab id, so it can neither make an absent tab id present nor touch `ptyIdsByTabId` — which is exactly why no test separates them. But they are not interchangeable: `state` is the staler of the two, and its failure direction is to RELEASE a pane whose row has come back. That is resolving unverifiable to exited, which is #19735. Holding on evidence that might be stale costs one frame; acting on it forks a transcript. Take the fresher read, and record in the comment that no test can fail on this and why that is not a reason to change it. Both guards remain killed by exactly one case each and they do not overlap: `replays a pane once per store write even when an earlier replay re-enters the drain` for identity, `does not release on retraction evidence a mid-drain adoption has already made stale` for re-judging. Verified while confirming the previous commit's test premises against production, both of which hold: a sweep that parks every record writes nothing to the store (resume-sleeping-agent-session.ts takes `continue` on the park branch), and `workspace-session-worktree-id.ts` moves `tabsByWorktree` onto the canonical id while leaving `sleepingAgentSessionsByPaneKey` naming the old one — the stale `worktreeId` the adoption case depends on.
…d take (#19865) * fix(worktree): ask the execution host whose home a remote delete would take `isDangerousWorktreeRemovalPath` read `os.homedir()` — the machine running Orca — and then applied POSIX-only shape rules. SSH orphan cleanup feeds it remote paths, so a Windows host profile (`C:\Users\bob`) was unrecognised from a macOS/Linux desktop and the recursive delete lost its last guard, while a coincidental client-home prefix could refuse a legitimate remote delete. The removal route already resolves one execution host for the whole removal; it now resolves one home authority the same way. `WorktreeRemovalHomeAuthority` is `{ kind: 'client' }` or `{ kind: 'executionHost'; homePath }`, required at every guard entry point, so the ambient read is unreachable from a remote removal. The host's answer is the `$HOME` the SSH session already read on the host during relay deploy — no new probe. Unresolved stays `null`, meaning unknown, never "same as this client's". Path-shape rules now cover Windows profiles (`C:\Users`, `C:\Users\<name>`, any drive or UNC root, case-insensitively) and WSL UNC aliases, which front a Linux filesystem and so take the POSIX shapes. Fixes #18275 * fix(worktree): merge the duplicated removal-route import The focused code-quality plugins deny `import/no-duplicates`. * test(worktree): pin the IPC removal call site and the unknown-host-home refusal Mutation testing found four survivors in the home guard: - Swapping the IPC unregistered-removal call site to the client's home passed every suite while the remote delete could reach the host's own home. Only the runtime call site was pinned. Add the mirror test for the IPC path. - Falling back to os.homedir() when the execution host reported nothing was indistinguishable from refusing; the client home never coincided with the probed path. Assert the fallback stays off with homedir pinned to the path. - Comparing an execution-host home across path syntaxes survived because no row exercised the win32 home under POSIX ops: path.resolve manufactures <cwd>/C:/Users/bob, which every ancestor of the cwd contains. - Dropping the bare /Users rule survived; add the row. Also cover the forward-slash C:/Users/bob form normalizeRemoteHome reports for a Windows host, which no existing row used. * ci: re-run after an unrelated Electron probe startup timeout * fix(lint): clear the casting and max-lines gates on the home guard Rebasing onto main brings two gates this branch predates: typescript/consistent-type-assertions at assertionStyle: never, and the 300-line ceiling that the added home lookup pushed orca-runtime-remove-managed-worktree.ts past. The fixture casts carry per-site SAFETY rationales; the route's git-options-and-listing step moves into its own module, which also stops the local/SSH branch being spelled twice in one expression. * fix(lint): name the home predicate for what it matches main enabled anti-slop/no-shape-in-symbol-names (#20785) after this branch was written. The predicate answers whether a path IS a home root, not whether it resembles one. * fix(worktree): refuse a removal the execution host cannot vouch for Review of the home guard found three ways it still let a delete proceed on evidence about the wrong machine, or on no evidence at all. `getPathOps` switches to win32 as soon as EITHER the worktree path or the repo path looks Windows-absolute, and `//nas/share/repo` does. A POSIX worktree path was then judged by Windows-only shape rules, which recognise `<root>\Users\<name>` and nothing else, so `/home/alice` — and any client home outside `\Users` — stopped matching and the last guard in front of a recursive delete went quiet. The home question involves the worktree path and a home, never the repo path, so the predicate now reads the path in its own syntax as well and refuses if either reading names a home. A union of refusals can only ever refuse more. An execution host that never reported its `$HOME` is `unverifiable`, and `unverifiable` does not authorise a delete. `isRemovalHomeAuthorityResolved` gates the two paths that recursively delete a directory — `canSafelyRemoveOrphanedWorktreeDirectory` and `canCleanupUnregisteredOrcaLeftoverDirectory` — because the orphan proof they accept, a `.git` file at the top of a directory, is also what a bare-repo dotfiles `$HOME` looks like, and there the guard is the only evidence there is. `git worktree remove` is deliberately not gated: the host's own Git registry already established that the path is a linked worktree of that repo, and a missing second opinion does not retract a first one. An empty `$HOME` is normalised to unanswered rather than read as a resolved home. The IPC entry point spelled its host two ways. The metadata prune, the archive-hook route and now the home authority came from `getRepoExecutionHostId(repo)`, while the `git worktree list` and every delete came from raw `repo.connectionId`. A row carrying only `executionHostId: 'ssh:<target>'` therefore listed a remote checkout on this client and deleted a same-named local path while the guards vouched for the remote one; the mirror row did the reverse (#11163, previously fixed on the runtime path only). Neither spelling is evidence about the other, so a row that carries two host names is refused before anything is listed or deleted. Both sides are spelled by `getRepoExecutionHostId`, so they can differ on content but never on normalisation. A `runtime:<env>` row refuses here for the same reason. It is not reachable through this handler today — the renderer sends environment targets to `worktree.rm`, and the host-qualified catalog refuses to list a runtime host — so that arm closes a door rather than changing a flow. Fixtures that register an SSH provider now report a host home, because a connected relay session always has one: `remoteCliBridgeEnv` is assigned before `registerSshGitProvider`, is never cleared, and providers are unregistered before the session leaves `activeSessions`. The wiring lives in its own module called from the harness rather than in `worktrees-test-module-mocks`, which `vi.mock` factories import: reaching the production route module from there pulls in `providers/ssh-git-dispatch` while it is being mocked, and the module runner deadlocks. * fix(worktree): compare removal host names after decoding, not as stored text `getRepoExecutionHostId` returns a row's `executionHostId` as stored, while the same row's `connectionId` is re-spelled through `toSshExecutionHostId`, which percent-encodes. A byte compare of the two would refuse a perfectly consistent row over a `%20`, so the two host ids are now compared after `parseExecutionHostId` has decoded the target id out of each. `runtime:<env>` and an unparseable id decode to no machine at all and match nothing, including each other — a runtime-owned row has a null `connectionId` and would otherwise read as local, which is a delete on this client. * fix(lint): clear the static-analysis gates on the removal home authority The type-aware audit rejects a `default` arm on a discriminated switch, so the host-kind switch names `runtime` and `undefined` outright — which also makes a host kind added later a compile error here rather than a silent fallthrough. The two test casts the changed-code gate flagged are gone: the leftover-cleanup meta is typed instead of asserted, and the unparseable-host-id case narrows to `ExecutionHostId` with the SAFETY rationale the gate asks for. * docs(worktree): say why an unroutable removal host is refused by a plain compare The comparison refuses `runtime:<env>` because only the left operand can name no machine — `repoRowHostId` comes from `connectionId` and is always `local` or an `ssh:` id. That invariant was doing the work silently; an explicit null test in its place was a branch no input can reach, so the reason is written down instead. * fix(worktree): gate the registered removal on the host home answer too I argued `git worktree remove --force` did not need the host's home answer, because the host's own Git registry had already established that the path is a linked worktree of that repo. That is true and it is not enough: `git worktree add` accepts a pre-existing empty directory, and that directory can afterwards be somebody's `$HOME` — a build account's home, a container's `HOME=/workspace`. Being a linked worktree proves provenance, not that the path is not a home, and the remove deletes the checkout either way. With the host's answer that case is already caught by containment. Without it only the path shapes remain, and a home at a non-standard location (`/var/home/<u>`, `/export/home/<u>`, `D:\\Profiles\\<u>`) has no shape to match. So `findRegisteredDeletableWorktree` now requires the answer as well, and every gate that authorises a delete is on the same rule. The fixture that models a connected relay session moves out of `ipc/` and is shared: four runtime specs register an SSH provider without one, and a live provider implies a reported home in production.
…stop round-tripping as deletions (#19572) * fix(session): give an SSH workspace one owning partition so its tabs stop round-tripping as deletions `workspaceSessionPartitionHostId` answered differently depending on who asked: the renderer mapped an SSH worktree's session to the `local` blob, the main-process runtime read-modify-wrote `ssh:<targetId>`. One workspace's session lived in two stores and no reader reunited them, so whatever landed on the unread side did not read as unknown — it round-tripped as absence. The remote-workspace upload is a `replace-session` patch, which turned that absence into deletion on the host, and the next pull applied the deletion locally and re-poisoned the snapshot. Collapse the two answers into one: every non-'local' host owns its partition. Boot hydration and the export fallback now read the SSH partition, and rows a shipping build left in `local` are folded back in once, gap-filling only — an empty tab row is a gap, never proof that anything was closed. Folder workspaces deliberately keep their existing 'local' routing: boot discovers SSH partitions from the repo catalog, so an SSH target that owns only a folder workspace has no partition any reader enumerates. They are still adopted back out of an SSH partition when a repo does name the host. Fixes #12721 Supersedes #12722 Co-authored-by: Robert Nisipeanu <github@nisipeanu.com> Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com> * test(session): pin the old-client empty-publish skew direction * fix(session): adopt every workspace the host partition names, not only tabbed ones Review caught that gating adoption on `host.tabsByWorktree[key].length > 0` traded the #12721 deletion for a narrower one. The write path routes EVERY worktree-scoped field to the owning partition, so an SSH workspace with open editor files or browser tabs and no terminals had all of it dropped on every restart — and unlike terminal state it cannot be recovered from the host snapshot, which carries terminal fields only, so an unsaved `dirtyDraftContent` was destroyed outright. The defect was not a missing field. It was a hand-maintained field list deciding what the read recovers while the write used the ownership table, so the two could disagree. Adoption now walks `WORKSPACE_SESSION_FIELD_OWNERSHIP` with an exhaustive switch, and a new ownership kind is a compile-time decision rather than a silent omission. Session keys are normalized through the shared `normalizeWorkspaceSessionKeyToWorkspaceId` so host-qualified visit recency (`ssh:target|worktreeId`) reaches its workspace, and the regression is pinned by feeding the shipping split's own output back through the real boot read rather than a hand-built fixture. Co-authored-by: Robert Nisipeanu <github@nisipeanu.com> Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com> * fix(session): stop adoption overwriting rows it was never told about Three losses, one cause: the reader walks its own description of the partition layout while the writer walks another, so the two agree on which ownership kinds exist and not on what a kind means. - An empty host row replaced a populated base row, destroying an unsaved dirtyDraftContent the header comment says must never be destroyed. The host holding nothing is not evidence the base is wrong. - A contested bare id was adopted as if local and ssh:<target> were one workspace written twice, which is exactly the id where that premise is false. The read already reached that verdict and adoption could not ask for it, so it is passed in; contested keys are gap-filled, never replaced. mergeWorkspaceSessionsWithHostShadow now reports the real contested set, which primaryHostBySessionKey never was. - Tab-, pane- and file-keyed rows are adopted through the split's own indexes, so unified-only tabs come back and the pane key is parsed once. - A bare lastVisitedAtByWorktreeId key only fills a gap; the split has a dedicated branch for that field and the reader had none. * test(session): pin the tombstone/gap boundary the two readings meet at An explicit empty tabsByWorktree row means the user closed the last terminal; adoption reads an empty base row as a gap to fill. Same value, opposite readings, so the boundary is asserted rather than argued: the tombstone lands in the owning partition, restores as a present empty row rather than a deleted key, is declined by the real seeding predicate, is published as an empty list, and the legacy-transition resurrection happens once and cannot recur. * docs(reliability): record the adoption guards and the tombstone boundary in the gate * test(e2e): read the SSH restart assertions from the partition that owns them ssh-cold-activation-restore asserted persistence through session.get() with no host, which is the local partition an SSH worktree's rows no longer live in. The invariant it means to check is that the state is persisted where the boot read will find it, so it now unions local and ssh:<targetId> and stays correct on both layouts. Confirmed the product invariant separately rather than by the edit: the behavioural half of both tests - the full app restart, the active worktree, the eager terminal remount and the PTY-owner reclaim against a real Docker OpenSSH host - runs after this check and passes. 2 passed in 48.9s. * test(e2e): read ssh-restart-tab-accumulation from the owning partition too Same layout-coupled read as ssh-cold-activation-restore: the pre-quit flush asserted through session.get() with no host. Verified against a real Docker OpenSSH target - both repeated quit/relaunch cycles keep exactly the restored SSH tabs, no accumulation and no loss. 2 passed in 52.9s. * fix(lint): clear the casting gate on the partition adoption main tightened typescript/consistent-type-assertions to assertionStyle: never, which the rebase brings onto these added lines. Most of the round-trip fixtures did not need a cast at all -- three were hiding wrong-shaped literals (a browser workspace keyed 'name', a unified tab keyed 'type', a layout keyed 'direction'), now written as the types they stand for. The adoption reads narrow through an isRecord predicate instead of casting, which also stops a null entry throwing out of Object.keys. What is left is dynamic-field writes and unknown-typed IPC returns, each with its own SAFETY rationale. * fix(session): give an SSH folder workspace one owning partition boot can find The partition owner rule already names `ssh:<targetId>` for a repo-backed worktree, but `getFolderWorkspacePartitionHostId` still answered 'local' for a folder workspace while main's `RuntimeWorkspaceSessionController.getPreferredHostId` answered `ssh:<targetId>` for the same key. That is #12723 unfixed for folder workspaces, and once the renderer started writing `ssh:*` at all it got worse: a save's field-level patch carries only the rows routed to that partition, so a `tabsByWorktree` write without the folder row erased the row main had put there. The reason the renderer could not route there was real - boot discovered SSH partitions from the repo catalog, which cannot name a target whose only workspace is a folder. So persistence now answers that directly over `session:list-host-ids`, and boot reads the partitions that exist rather than the ones a catalog implies. Removing a folder workspace prunes its rows from the owning partition too, or the census would adopt them back on the next launch as a workspace the user already deleted. Adoption now decides from the repo catalog instead of from co-presence. Two partitions holding one bare `repoId::path` is not evidence of a collision - that is the exact shape the repair exists for - so the verdict comes from `resolveWorktreeExecutionHost`: a repo id registered on more than one host is contested and may only be gap-filled, and one the catalog positively resolves to a different host is residue this partition does not own and is not adopted at all. Without the second rule a stale partition sorting first won the read and was then written into the live one. Nothing is deleted either way; the rows stay where they are. Finally, a workspace adopted out of a partition now routes back to that partition. Routing used to re-derive an owner from the catalog, so a boot whose repos had not hydrated moved the rows it had just reunited back into 'local' and re-stranded them. Contested ids are withheld from that override, because routing the whole bare id to one host is the loss the gap-fill prevents. The publish path resolves each workspace's owner once for the whole publish, shared with the projection, so the per-target catalog attribution does not repeat it per connected host. * fix(session): drop a deleted workspace from every partition, not just the local blob Adversarial review of the previous commit found three ways the partition census - which now reads whatever persistence holds rather than what the repo catalog implies - keeps rows alive that nothing should keep alive. `deleteProjectGroup` pruned only the local blob, so every folder workspace under a deleted group left its rows in `ssh:<targetId>`; the next boot adopted them back, named that partition their owner and wrote them there again, forever. `removeFolderWorkspace` had the same hole for a workspace whose partition its host expression could not name: main never persists a folder workspace's `executionHostId`, and `RuntimeWorkspaceSessionController` can infer a connection from the group's repos that the workspace row itself does not carry. Deriving the partition at delete time is the wrong question - a deleted workspace owns nothing anywhere - so both paths now remove it from every partition. The third is on the read side. A contested id is deliberately withheld from the read-source override so the write cannot carry one host's rows into another's partition, but the routing that then re-derives an owner answers 'local' for an id the catalog cannot name. Adopting such a row moved it out of the partition that owns it and into the blob: the two-store split this change exists to remove. A contested id the assembled session holds no row for is therefore not adopted at all. Gap-filling stays available for a contested id the session already names, since that row's own partition is what the write follows. Declining to adopt leaves a row invisible for one boot; it never deletes one. Also: the folder-key guard in both catalog attributions was dead, because `getRepoIdFromWorktreeId` hands back the whole key rather than nothing when there is no `::`. The verdict was right and the resolution wasted; it now skips by shape. And the two type assertions the changed-code casting gate rejected are gone rather than suppressed. * fix(session): park the rows a partition read declines instead of letting the next write erase them A partition write replaces each field with exactly what the unified session routed there. So a row the read left out of that session is erased from its own partition the moment any sibling workspace writes the same one - and with SSH partitions now the owning store, that row is then in no partition at all. Three separate decisions produce such rows: residue the catalog attributes to another host, a contested id withheld so the write cannot carry one host's rows into another's partition, and a workspace the base already holds the live copy of. Declining to show a row was quietly deleting it. The machinery for this already exists. `attachHostSessionShadow` writes a contested runtime co-claimant's parked rows straight back into its own slice before the write, so the primary's write cannot erase them; the ssh partitions simply were not among the slices the contention split arbitrates. The read now parks everything it is not returning to an ssh partition into that same shadow, and the existing re-attach puts it back. Leak, never kill - docs/reference/ssh-execution- boundary.md - and a row no partition holds is unrecoverable. Second, the contested branch of the tab adoption read `Object.hasOwn` as "the base has tabs here". An empty list satisfies it, so whenever a legacy id happened to be contested, #12721's empty local row won over the host's real one - the exact reading the module's own header, and the gate invariant it is pinned by, say is wrong. An empty row is the gap this repair fills, so it is now treated as one. * test(session): pin the empty-base-row gap for a contested id Mutation testing found the assertion missing: reverting the gate to `Object.hasOwn` left all 39 assertions passing, which makes the fix that reads an empty base tab row as a gap unguarded. The #12721 shape does not stop being a gap because the id happens to be contested. --------- Co-authored-by: Robert Nisipeanu <github@nisipeanu.com> Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
…ands (#20069) * fix(mobile): stop a created tab from jumping when the host snapshot lands Creating a tab from the mobile session strip painted the new tab at the end of the strip and then visibly jumped it to a different slot a beat later. The client asked the host to insert the tab after the active tab, but then predicted a different placement for its own optimistic paint: afterTabId: activeSessionTabId ?? undefined // host: splice(insertAfter + 1) ... return [...prev, { ...created, isActive: true }] // client: append Two independent placements that disagree, so the optimistic frame is wrong by construction and the tab snaps to its real slot on the next published snapshot. The disagreement dates to 57a70d2 ("Fix mobile session tab authority"), which introduced afterTabId and left the append in place. Before that the client used terminal.create with no anchor, so both sides appended and agreed. Rather than teach the client to re-derive the host's rule, both sides now call one shared placeCreatedSessionTab, and the client captures a single afterTabId for the request and the paint so they cannot drift apart again. The host change is a pure refactor onto the shared helper; the 1260-test runtime characterization suite is unchanged. The mobile route-parity hash pin moves once because handleCreateTerminal's body changed - it is the only one of the 12 extracted functions that differs. * fix(mobile): keep split terminal placement stable * fix(mobile): negotiate split tab placement * fix(e2e): run worktree first-paint probe on a mapped window * test(mobile): type tab placement updater * test(mobile): model current host in create recordings --------- Co-authored-by: Merge Sim <sim@local>
…lips (#21161) * fix(relay): serve readiness from last-known-good during auth or SQL blips The load balancer health check hits /ready, which re-probed the auth JWKS endpoint and Postgres on every poll and reported not-ready on the first failure. On 2026-09-16 an auth outage therefore took every cell out of the load balancer within ~30s and dropped every connected host, even though the token verifier caches keys in process and kept verifying tokens. /ready now remembers when each dependency last answered and keeps reporting ready while the failed one stays inside a grace window (ORCA_RELAY_READINESS_GRACE_MS, default 15 minutes, 0 disables). A process that has never succeeded still gates on the real dependencies, so cold boot is unchanged. Grace answers carry degraded plus the failure reason on the existing readiness observation, and entering or leaving grace logs once. MIG autohealing still uses the dependency-free /health endpoint. * fix(relay): split readiness grace per dependency and probe both every poll Review follow-ups on the last-known-good readiness window. An unset environment variable arrives as an empty string, which z.coerce reads as 0, so the single ORCA_RELAY_READINESS_GRACE_MS would have switched the window off instead of falling back to its default. The two replacement variables preprocess '' to undefined. JWKS and SQL now get separate windows and separate clocks: ORCA_RELAY_READINESS_JWKS_GRACE_MS defaults to 15 minutes, and ORCA_RELAY_READINESS_SQL_GRACE_MS to 3 minutes. Each cell is its own load balancer backend, so failing readiness never re-routes a host, it only makes that hostname unreachable, and a host that lands on a SQL-dead cell gets WRONG_CELL and is re-placed by the director. Three minutes rides a Cloud SQL failover without hiding a per-cell fault for a quarter of an hour. Both dependencies are probed on every poll. A JWKS failure used to short-circuit the SQL probe, which let the SQL clock age with no evidence behind it. Grace transitions are emitted per dependency, so JWKS recovering while SQL fails logs both sides instead of nothing. /ready keeps its 200 and its {ok:true} body when healthy, and adds degraded plus the dependency list when the answer comes from a window.
…all failed (#21151) * fix(native-chat): stop a collapsed run claiming success when a tool call failed A settled activity group drew its completion mark whenever no call in it was `running`. That is not a success test: a tool call is `running`, `completed` or `failed`, so a run whose call failed had nothing running, took the mark, and asserted success over a failure the reader could only find by expanding the run. Success is now stated rather than inferred. `nativeChatToolRunSucceeded` grants the mark only to a run that is settled, has nothing still running, and has no failed call — a call's own `failed` verdict or an error result, the same composite test the task-list, edit-card and ask-row readers already use. A call with no lifecycle state is neither, so legacy transcripts still settle. A collapsed run that did contain failures now says so in the header, as a quiet `N failed` in the header's own mono type with a spoken `Failed tool calls: N`. Text only: a tool error is routine work, so no destructive tint and no swapped glyph. The count is taken over every call in the run, not the latest. * fix(native-chat): count failed tool calls without result mispairing
…ed connection ceiling (#21163) * infra(relay): raise asia-east2 cell pools to 16 and retire four idle cells The three asia-east2 cells sit 176 ms from the Cloud SQL instance in us-central1. Server-side statement time there is 0.2 ms, so a pool slot is held by the round trip, not by the query. At a pool of 10 they measured 94-156 waiters and 2 s waits, and client accepts ran a ~4 s p95 against 222-646 ms in us-central1. Raising those three pools to 16 is the agreed first step; every other cell stays at 10. c4 and c5 join the committed fence set. Both are existing-only capacity the admission selector can never place on again, they carried ~1 connection each on 40-day-old images, and each still holds 10 Postgres connections. The fence set is the prerequisite the fence-source workflow confirms before it drains and attests a cell; it is not itself the resize. c17 and c18 are not fenced here. They are migration-only, and the runbook requires retire-migration-cell to move a migration-only cell to existing-only through a generation-bound selector CAS before it can be fenced. Terraform cannot express that step. The Cloud SQL consumer contract carried two stale numbers: auth at 2 instances when production has run a cap of 20 since 2026-09-04, and a 400-connection ceiling when the live instance reports 500. Both are corrected, and the budget now asserts its headroom in two named gates instead of one aggregate boolean. Those gates fail: auth alone accounts for 200 configured connections and a 215-connection rollout overlap, so the operating maximum is 713 against a usable ceiling of 490. Nothing here caused that, and no pool was lowered to hide it. * infra(relay): move the Cloud SQL contract correction out of this branch The contract correction (auth at its real 20-instance cap, the measured 500-connection ceiling) makes the budget gate fail for reasons that have nothing to do with asia pools or fenced cells, and it held this branch red. It moves to its own branch where the failure is the subject. production-cloud-sql-app-consumers.json returns to main unchanged. The budget test keeps main's single gate and only repins the cell figure that this branch genuinely moves: 230 -> 228, being +18 for three asia pools at 16 and -20 for fencing c4 and c5. Against main's 400-connection model that leaves an operating maximum of 383 under a usable ceiling of 390. * infra(relay): move the c4/c5 fence entries out of this branch Terraform now sets a cell's MIG target size directly from relay_gce_fenced_cells (relay-gce-cells.tf); the lifecycle ignore that used to protect operational target_size drift is gone. So a fence entry sitting on main ahead of its fence-source run is a standing instruction that any apply reaching that cell may execute without the documented drain and attestation. Keeping the entry in the same merge as an unrelated pool change widens that blast radius for no reason. The two entries move to their own branch, to be merged immediately before fence-source runs for c4 and then c5. This branch keeps the multi-line reflow of the list, which makes that later diff two added lines instead of a rewritten one. The cell figure in the budget test follows: 230 + 18 for the three asia-east2 pools at 16, with no fenced-cell subtraction. That is 403 operating against a usable ceiling of 390, so the headroom gate now fails by 13. It fails against a ceiling of 400 that is itself wrong; the instance reports 500. See the PR body. * infra(cloud-sql): record the measured 500-connection ceiling The budget's usable ceiling came from maxConnections: 400, described as the tier default. It is a tier default, since no max_connections flag is set, but the instance does not report 400. SHOW max_connections on it returns 500, measured 2026-09-16. On main the model sat at 385 against a usable ceiling of 390, five connections of margin, so raising the three asia-east2 pools by 18 failed the gate by 13 against a ceiling that was never checked. Against the measured one it is 403 against 490, clearing by 87. Only the ceiling and its source note change here. auth stays recorded at 2 instances, which is also wrong; PR #21165 corrects it, and with the true auth figure the budget is over by 225 for reasons that have nothing to do with these pools. * test(cloud): state the cell pool arithmetic literally in the budget pin comment
…proval (#21090) * feat(native-chat): render a proposed plan as a plan, not a generic approval A finished plan arrives as an ExitPlanMode tool call. With no handling for it, the generic approval path serialized the tool input, so a plan appeared as thousands of characters of escaped JSON. A plan is content to read, not a privilege to grant. Classify the plan in the permission callback and carry it as a typed subject on the approval item, keeping the existing approval kind so the prompt still reaches every consumer. Mobile filters pending approvals on that kind, so introducing a new one would have made the prompt vanish there silently. Classification runs before registration, so a future permission-mode short-circuit cannot swallow a plan proposal. The assistant tool-use stream is a second ingress and is pinned by its own test, because neither path can be assumed to fire on its own. Rather than adding a second card, the plan renders inside the approval card's existing bounded content region. It inherits the height cap, the scrolling, the keyboard focus and the pinned action row that region already provides, and a typed plan replaces the raw detail instead of rendering both. Buttons read as plan decisions. Mobile renders the same subject through its own markdown component in the same region. * fix(native-chat): preserve plan review semantics * fix(native-chat): keep plan approval one-turn
…omain (step 7) (#21169) * refactor(mobile): checked reply readers for the tasks item and list domain (step 7) Thirty-eight unchecked reply readers across four tasks files become checked zod readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError` naming the method instead of a downstream `TypeError`, a rendered `undefined`, or a sheet left ready over garbage. Deliberately a behaviour change on malformed replies only; nothing on the wire moves. mobile-task-item-state-operations.ts 17 mobile-task-item-detail-operations.ts 8 mobile-task-item-comment-operations.ts 7 mobile-task-list-operations.ts 6 Two rules decide every schema, and both are stated in task-provider-entity-reply-schema.ts: 1. A member is required only where a tasks consumer reads it with no guard. Everything reached through `?.`, `??` or a `typeof` test stays optional, because a reply without it rendered the same fallback then and now. 2. No member is required that the site's own recorded `normal` reply lacks. The corpus is the only evidence of what a host really sends at each site, and requiring a member absent from that control would turn a good reply into an incompatible one. Rule 2 holds two schemas at the container: `github.prFileContents`, whose recorded reply is `{ oldContent, newContent, truncated }` where `getPRFileContents` returns `{ original, modified, ... }`, and `gitlab.todos`, whose recorded row is not a `GitLabTodo` and whose `normal` partition therefore records main crashing in `actionName.replace`. Both still gain their container, which is what names a reply that is not an object or not a list. Correcting those two scenarios is the follow-up that unlocks narrowing the rows. Nine writes share one envelope reader and five comment writes share another: `ok === false` and `error` are one host convention across them, and no input would make two of them want different answers. The acceptance, the name and the recorded family stay per operation. Three readers are reused rather than re-declared — the session domain's boolean confirmation for `setPRFileViewed` and `resolveReviewThread`, and its salvaged-member combinators throughout. Three call-site shape tests the reader now answers for are deleted: both `Array.isArray(payload)` guards on the checks read and the `typeof count === 'number'` fallback on the item count. `GitHubPRFileContents` is widened to optional members, which is what the reader can promise, and `buildGitHubPrFileDiffPreview` takes the widened sides — `splitContentLines` already treated a falsy side as no content, so no runtime behaviour moves. The tasks source-parity hashes are refreshed: hook, statement, declaration and render-token counts are unchanged, the render-token hash does not move at all, and `semantics` is a pure deletion of ten lines. Inventory: 137 unchecked readers over 30 files becomes 99 over 26. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the RPC recording corpus and re-record the tasks reply deltas `baseline` moves to 9133c02, the commit that made the tasks item and list readers checked, and the whole corpus is re-recorded from it. The repin rewrites the `baseline` header of every golden; the body moves are the disclosed behaviour change and nothing else. What moved, and why: a malformed reply at one of the thirty-eight migrated read sites used to reach the consumer as the declared type and fail downstream — a property read on `null`, `.map` on a string, a rendered `undefined`. It now stops at the operation boundary as one `RpcIncompatibleReplyError` naming the method, which each call site's existing `catch` shows where it showed the `TypeError` before. Every move is confined to a malformed reply partition of a `tasks.*` or `linear.issue-detail` family; no `normal` partition moves, and no family outside the tasks domain moves at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): anchor the tasks reply readers' null-collapse mutant One registered mutant for the class the session domain shipped twice before a review caught it: the assignable-user row's explicit `avatarUrl: null` collapsed into absence. `tk-item-detail-metadata` records that null in visible state, so the pilot suite kills it; it also fails the unit pin beside the schema and both of that family's matrix goldens, including their `normal` partitions. Three by-hand experiments back the other two claims the corpus should hold. Applied to the product source, run, reverted: - Loosening a required member the consumer reads with no guard — `linearIssueSchema`'s `title` to a salvaged optional — fails the unit pin `one Linear issue: refuses a reply missing a member createLinearTask reads with no guard`, and `tsc` rejects it, because the loosened output is no longer a `LinearMobileIssue`. No golden moves: the reply matrix varies the envelope a host sends, never the shape of a row inside a result, so a row requirement is unreachable from the corpus and the unit pin is the only thing holding it. - Loosening a *container* requirement is reachable: making `linearAccountStatusSchema` `.nullable()` fails one matrix golden, `tasks.provider-load: reply partitions at linear.status#1`, on the `result-null` partition, as well as its unit pin. - Swapping one checked reader back to unchecked — `githubPullRequestChecksRead` to `rpcUncheckedPayloadReader` — fails two matrix goldens, `tasks.item-review-github` and `tasks.project-row-review-checks`, both at `github.prChecks#1`. Nothing under `mutants/` is pinned by a golden header, so this moves no recording. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct the gitlab.todos fixture to a real GitLabTodo row The `tk-list-gitlab-todos` reply sent `[{ id, targetType, target: { … } }]`, a shape `listTodos` never produces: the host returns `GitLabTodo[]`, whose row carries `actionName`, `targetTitle`, `targetUrl`, `projectPath` and `updatedAt` flat. Main crashed on it — `Cannot read properties of undefined (reading 'replace')` from `createGitLabTodoTask`, with the list rendering as an empty inbox and a raw TypeError on screen. That crash was being read as evidence: a reader could not narrow this row without "refusing the site's only success control", when the control was never a success. The fixture is the defect, so the fixture is what moves. The row is now a real `GitLabTodo` (src/shared/gitlab-types.ts:219) and main renders it: one item titled "A GitLab todo", subtitle `group/project #4`, status `review requested`. Recorded from the pinned main tree, not from this branch, so the corrected fixture's main projection exists as a golden before any reader touches it: a detached worktree at `4b876758d3158a8eb6b798055d8db7c58d1cd4a9` with this branch's manifest laid over it and its `baseline` set to that commit, per the recorder README's detached-pin recipe. Control: all 756 other goldens reproduced the base corpus byte for byte; only these two moved. Both keep `baseline: 4b87675`, which is the tree that produced them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): check the GitLab to-do row and drop its cast With the fixture corrected, the row is readable, so the reader reads it. `gitlabTodoSchema` requires the five members the screen reaches with no guard — `id`, `actionName` (read as `actionName.replace`), `targetUrl` (what tapping the row opens, and the title's fallback), `projectPath` (the subtitle and the repository badge's key and label) and `updatedAt` — and leaves every guarded member optional: `targetTitle` behind `targetTitle || targetUrl`, `targetType` and `targetIid` behind the two tests in `gitLabTodoTargetRef`, and `authorUsername` and `state`, which this screen carries but never reads. The list salvages: one unreadable to-do drops and the rest of the inbox still renders, which is what the rest of this domain does with a row it cannot place. Nullish still reads as the empty inbox the call site already read. `GitLabTodo` in mobile-tasks-provider-detail-types.ts now says what the reader proves rather than what the host declares, which is what lets the `as GitLabTodo[]` assertion at the call site go. It was the last cast in this domain's consumers, and it was re-typing rows nothing had checked — the phantom-field shape this series exists to remove. Parity: the same three hashes the step-7 commit moved move once more, for the deleted cast and the widened row type. Counts are unchanged and `semantics` does not move. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus after the to-do fixture and row check Repins `baseline` to the commit that checked the GitLab to-do row and re-records all 758 goldens, so the whole corpus is pinned to one tree again: the two `gitlab.todos` goldens were still pinned to main's, which is the tree that produced their before-picture. The disclosed move is the `normal` partition of `tasks.task-list-gitlab-todos`. That is the fixture correction showing through, not a reader change: with a real `GitLabTodo` on the wire the list renders one item where it used to render a TypeError. Every other body move in this refresh is a malformed-reply partition, as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct the github.prFileContents fixture to the host's shape The recorded `normal` reply at both `github.prFileContents` sites was `{oldContent, newContent, truncated}`, which `getPRFileContents` never returns: it answers `{original, modified, originalIsBinary, modifiedIsBinary, originalTooLarge, modifiedTooLarge}` (src/main/github/pull-request-file-contents .ts:121-128), with the two too-large flags set only where a side was skipped for size (:54). Both sites' `normal` partitions therefore proved nothing about the success path, and `githubPullRequestFileContentsSchema` cited that fiction as the reason it could require nothing. Same defect class as the `gitlab.todos` fixture corrected in 32bd65c, found by round-1 review. Before-picture recorded against main's own product tree, not this branch's: a detached worktree at `4b876758d3`, this branch's `pilot-scenarios.json` copied in with `baseline` set to that commit, per the recorder README's detached-pin recipe. Control: 744 of the 758 goldens reproduce the base corpus byte for byte; the 12 copied here are the two affected families, and the remaining two are the already-corrected to-do pair, which reproduced 32bd65c's bytes exactly. The 12 carry `baseline: 4b87675`, the tree that produced them. The next commit's reader change and the repin that follows it re-record them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record main's reaction rendering for both providers No scenario in the corpus carried a comment reaction, so nothing in the 758 goldens could see what a reader does to one. Round-1 review found the consequence: this branch closed `reactions[].content` to a mobile vocabulary (`thumbs_up`) that no producer sends, and the corpus stayed green because the member was never on the wire. Two scenarios, one per provider, each a second scenario in an existing family so no matrix base and no existing golden moves. The GitHub one carries `GitHubReactionContent` as the host sends it — `'+1'` and `'heart'` (src/shared/github/comment-types.ts:3-17, normalised from GraphQL at src/main/github/comment-reactions.ts:19-27). The GitLab one carries `GitLabReaction`, which is `{ name, count }` with no `content` at all (src/shared/gitlab-types.ts:60-72). Recorded against main's product tree at `4b876758d3` through the detached-pin worktree, so the `normal` partition now pins what main renders rather than what this branch renders. Control: all 758 existing goldens reproduce the previous pin recording byte for byte; the only difference is these two added files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): forward a comment reaction instead of matching mobile's vocabulary `DETAIL_REACTION_CONTENT` was `thumbs_up | thumbs_down | ...`, a vocabulary no producer of this list sends. GitHub answers `github.workItemDetails` with `PRComment[]` whose reactions are `GitHubReactionContent` — `'+1'`, `'-1'`, `laugh`, ... (src/shared/github/comment-types.ts:3-17), normalised from GraphQL at src/main/github/comment-reactions.ts:19-27 — and GitLab answers with `GitLabReaction`, `{ name, count }` with no `content` member (src/shared/gitlab-types.ts:60-72). The closed arm set dropped every real reaction row on both providers, which is a good-reply path this PR must not change. `content` is forwarded now, salvaged the way every other guarded member here is; `count` stays required, because the `count > 0` filter at mobile-tasks-item-comments.tsx:145 is the one unguarded read. `DetailComment`'s eight phantom arms go with it: mobile's declared type was written from memory, not from the wire, and widening the type is the fix rather than narrowing what the host may send. `COMMENT_REACTION_EMOJI` was keyed by that same phantom vocabulary, so it resolves no glyph for a real reaction and the chip renders without one. That is a pre-existing defect and it stays exactly as it is: the map is typed `Record<string, string>` and the lookup takes `?? ''`, which resolves to the same `undefined` main resolved for both providers. The two scenarios recorded in 68a3db2 pin that rendering, so a future arm set cannot drop the rows unseen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): stop citing the file-contents fiction as the reason nothing is required The schema comment and its unit pin both named `{ oldContent, newContent, truncated }` as "the recorded reply", which d26aeec corrected. The rule that keeps every member optional is unchanged and is now stated from the reads instead: the call site files the payload under the file path and reads nothing off it, the review panels reach each flag through `?.`, and `splitContentLines` takes `string | undefined` behind a falsy guard. The pins move to the host's own shape, plus the too-large pair a skipped side carries and the empty object that shows no member is required. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): import MERGEABLE_STATE instead of redeclaring it `task-item-detail-reply-schema.ts` declared its own copy of `['MERGEABLE', 'CONFLICTING', 'UNKNOWN']` while already importing four member helpers from `../session/github-pr-entity-reply-schema`, which exports that arm set and uses it in the identical expression three times. Two copies of one wire arm set is one place to fix when a provider gains a fourth state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): inline the alias-only bindings the deleted casts left behind Each of the eight was `const result = x as { ok?: boolean; error?: string }`. With the cast gone the line is a rename of a binding that already has a name, and every one of them is followed immediately by the same `ok === false` check. Reading `created.ok` / `updated.ok` / `written.ok` / `replyResult.ok` directly leaves one name per value. The parity constants move with it and with the reaction change before it. The comment there names both: ten string literals leave `semantics` with the phantom reaction vocabulary and one arrives with the `?? ''` fallback, and the alias deletions move the hook and statement hashes. No `rpc:` or `jsx:` signature moves, the render-token hash does not move, and the hook, statement and declaration counts are unchanged at 350, 417 and 194. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): register the container-requirement mutant the matrix kills `f1b695f161` proved by hand that making `linearAccountStatusSchema` `.nullable()` fails the `result-null` partition of `tasks.provider-load`'s matrix, and left it unregistered. Registering it in `pilot-mutants.test.ts` is not available: that suite drives the manifest scenario as written, and `tk-provider-load` scripts a fulfilled `linear.status`, which a nullable container accepts exactly as the required one does. The mutation only has somewhere to diverge under a partition the pilot never reaches. `family-mutants.test.ts` drives one named variant of a family's matrix instead, against that variant's own slice of the golden, and each entry names both the partition that kills the mutation and one that cannot see it — so the entry records where the coverage is rather than that some golden went red. The comparison is the whole recorded variant, the way the family suite compares: under a last-state projection this mutant survives, because the divergence is in the settlement and both paths reach the same final screen state. Nothing under `mutants/` is pinned by a golden header, so this moves no recording. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus after the round-1 review fixes `baseline` moves to 542c1c3, the last fenced commit, and the whole corpus is re-recorded against it. The two fixture corrections and the two added reaction scenarios were recorded at main's pin first, so this run is what carries them onto the branch's own reader. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the Linear create arm's own binding out of the inlining The alias inlining swept one line further than the finding it answers: the Linear arm's `const result = linearIssueCreate.interpret(reply)` is a declaration with a name, not an alias for one, and renaming it put a pre-existing `createLinearTask(...) as Extract<TaskItem, …>` assertion from #17438 inside this branch's changed lines, where the changed-code casting gate attributes it to this PR. Reverted to `result`; the eight bindings the review listed stay inlined. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): let each module build its own Linear team reader `linearTeamListReader` was exported from the detail-operations module only so the list-operations module could import it, which adds an operations-to-operations import edge that buys nothing: `rpcResultVariant` is a pure factory, so two calls with the same schema produce two functionally identical readers. What keeps the composer's picker and the saved-selection reconciler agreeing about a team row is that both build from `linearTeamsSchema`, which is already exported. Deleting the export also puts the composer-policy JSDoc back on `linearComposerTeamListRead`. JSDoc binds to the next declaration, so the block explaining why this method carries two operations with different acceptance policies had drifted onto the reader, leaving the operation it is about undocumented. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the last round-1 commit `baseline` moves to 75c568c and all 760 goldens are re-recorded against it. Nothing but the `baseline` header moves: the two product commits since the previous repin — the Linear create arm's binding restored and the team reader built per module — change no observation in any recording, which is what a pure refactor should look like here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): repin the RPC recording corpus to the main merge The merge of origin/main moved both lockfiles, which the recorder fences, so --record refused until the pin named a commit whose fenced tree matches this one. Repinned to the merge commit and re-recorded: 760 goldens, header only, `baseline` and `lockfileSha256`. No observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): point the reply-schema citations at the lines they name The alias inlining and the detail-loader rewrite moved fifteen of the consumer lines these comments cite, and the comments are the evidence for requiring a member only where the consumer reads it unguarded. Every citation re-checked by opening it; the host-side ones were already right. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): stop repeating the file-contents fiction on the type The corrected fixture carries four of the six members at both call sites, so "the recorded reply carries none of these" is false. The reason is the one the schema already gives: no reader reaches a member without `?.` or splitContentLines' falsy guard. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): forward a file's viewed state instead of closing it No scenario reply carries a file row, so no golden can observe either of this schema's two arm sets. `viewerViewedState` is read only by two `=== 'VIEWED'` tests, so it is forwarded and an arm this build predates reaches them as itself. `status` stays closed: its only consumer sends it straight back as a `github.prFileContents` param, which the host validates against the same seven arms (github-pull-request-params.ts:62). Forwarding could not reach the wire without a cast, and the host would refuse the arm on its own params, so the drop to absent that becomes `?? 'modified'` is the compatible read. Parity: one declaration and three arm literals, no executable change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that a Linear state-update refusal is ignored, as on main `require-result-or-throw-message` throws only on an outer refusal, and `linear.updateIssue` refuses in band as `{ ok: false, error }` on a successful envelope, so the refusal reaches no `catch`. Main read the same payload unchecked and discarded it, so nothing here changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the RPC recording corpus to the origin/main merge Main's #20069 re-recorded ten session create-terminal goldens and moved twenty-one files under src/shared, both inside the recorder's fence, so `--record` refused until the pin caught up. The re-record moves one key, `baseline`, in all 760 goldens and nothing else; the ten taken from main reproduce byte-for-byte apart from that pin. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The merge swapped RelayDrainRetrySchedule for RelayRetrySchedule, whose cancel() deliberately keeps the attempt count (the coordinator gates its own reset on resetRetry). closeNow still called reset(), which only zeroes that counter and leaves the armed drain timer running past close. Nothing caught it: reverting closeNow to reset() left all 222 relay tests passing. This is #20258's teardown test, ported verbatim so the two merge as identical content; it fails with reset() and passes with cancel().
…orks (#21149) * Show the Source Control AI CLI arguments field only where it applies * Fix Source Control arguments on remote launches
…or the corpus does not reproduce (#21156) * test(mobile): fail CI when the RPC recording pin leaves main's history `mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden claims it was recorded from, and `--record` refuses on any other tree. A behaviour-change branch pins its own last fenced commit, which stops being reachable the moment the branch squash-merges: nobody can record on main again until a hand-made repin lands, and until now only a human noticed. #21123 was that, and so was the repin after #20954. `scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an ancestor of the commit under test, and prints the repin recipe. It refuses to answer on a shallow clone rather than trusting grafted history, so the job checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin is not a failure. `reproduce` makes the other claim the corpus header makes, which the recording suites do not: they replay the goldens against the CURRENT tree, so a golden recorded somewhere other than the pin -- a merge that auto-merged golden JSON, a refresh copied back from a scratch directory -- passes them and is what the header exists to deny. It checks the pin out detached, lays this tree's recorder and manifest over it, and lets the same suites compare in place, so the comparison is `compareGolden` with lockfile and platform masked as ever. It runs unconditionally on a push to main, which has no `verify` job and is where a squash lands a spliced corpus. On a pull request it runs only when the corpus, the manifest or the recorder moved: nothing else can move the verdict away from the one the base commit published, and `verify` replays the corpus against the branch tree meanwhile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): judge the recording pin against the tree it was read from Round-1 review of the pin guard. The pull_request ancestry check read the pin out of the merge preview and judged it against the branch head. Those differ whenever main repins after the branch point, so ordinary stale branches failed, and the instruction told the author to repin to their own head -- which creates the unreachable pin the guard exists to catch. Judge the checked-out tree instead. `git worktree prune` in the reproduce teardown was repository-wide. This git directory is shared by every worktree on the machine (611 registered here), so it could deregister an unrelated one whose directory was momentarily missing. `worktree remove --force` alone is enough; a failure to remove is now reported rather than papered over. Also: the concurrency group is per commit on main, because GitHub cancels a pending run in a group whatever `cancel-in-progress` says; the skip gate fails closed when a provenance path stops matching instead of skipping forever; the census-boundary comment states the rule the code uses; and five exports with no consumer are now module-private. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): let an untracked golden and the guard itself buy a reproduction Two bot findings on the skip gate. `git diff` sees tracked paths only, but the reproduction's overlay copy and its census both read the corpus directory as it sits on disk, so an untracked golden or manifest is input to the verdict and used to skip the run that would judge it. Enumerate untracked entries under the provenance paths the way the recorder already does, and run rather than skip: an unjudged local addition is the case the reproduction exists for. The guard script is now a provenance path of its own, so a change to it re-runs the reproduction it implements. Left alone deliberately: run-process.ts and the workflow's `paths:` scope over src/shared, which is a pre-existing gap for the whole mobile workflow rather than this job's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refuse to reproduce when the suite list has drifted from the files Round-2 review. The suite names reach vitest as positional filename filters, and vitest exits 0 when only some of them match. A renamed census suite therefore dropped out of the reproduction silently and the guard still printed that the corpus reproduces: three files and 761 tests instead of four and 762, exit 0. Resolve every name under the recorder overlay before spawning, and throw naming the drifted entry. The unit case walks the list and omits each name in turn, so no single rename can slip past it. This is the same fail-open shape as the renamed-pathspec finding. Also: pass an explicit directory type to `symlink`, since Windows needs one and a junction needs no privilege where a real symlink does; and build the throwaway test repositories with `symbolic-ref` rather than `--initial-branch`, which needs git 2.28 against a declared baseline of 2.25. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…dator (#21171) * fix(cloud): pin the asia cell database pool in the same-cap plan validator Raising `database_pool_max` from 10 to 16 for production-gce-c27, c28 and c29 made every same-cap roll of those three cells fail closed at plan validation. The cell startup template emits `ORCA_RELAY_DATABASE_POOL_MAX` only for a cell whose region differs from the root region or whose pool is off the default, so the asia cells carry that line while the us-central1 cells do not. The plan validator requires the before and after startup scripts to normalize to the same text, masking only the lines it independently pins to a reviewed value. The pool line was neither masked nor pinned, so the live template's `'10'` and the plan's `'16'` were read as unreviewed drift. The validator gains an optional `--database-pool-max`, accepted in `same-cap-cell` mode alone. When it is supplied the after-script must contain exactly that pool line and the line is masked from the equality check; when it is not supplied the after-script must contain no pool line at all. Masking without the pin would have removed the guard rather than moved it. The same-cap job resolves the expected pool next to the hard cap, cross-checks it against the committed `relay_gce_cells` map (asserting the default 10 for the us-central1 cells), and passes the flag to both validator invocations only for the cells that emit the line. * test(cloud): require the pool pin for a line the live template already carries
Teardown here is a hand-maintained list of verbs against three helper classes that each spell "disarm" differently, and that is the whole defect: `drainRetry.reset()` sat beside `rotation.cancel()` looking symmetric while only zeroing a counter. Every behavioural test stayed green, because the fire-time guard meant nothing reopened — the leak was a retained timer with no behaviour at all. Counting timers is the only assertion that can see it. Turns the single drain-retry case into a table covering every timer the pool and its broker can leave armed, each reached the way production reaches it: control rotation and lease refresh (armed by openInitial and connect), the drain retry (armed by a director refusal), and an origin retirement grace (armed by a rotation to a different cell, with something still answering through the old origin so it is not retired on the spot). Each case asserts something was actually armed first, so a case that armed nothing cannot pass as 0 === 0 — which is how the original leak stayed invisible. Mutation-verified per verb: dropping `rotation.cancel()` fails all three; reverting `drainRetry.cancel()` to `reset()` fails only the drain-retry case; dropping `retirement.clear()` fails only the retirement case.
Replaces the single drain-retry case with #20258's current it.each table over every timer the pool and its broker can leave armed (rotation + lease refresh, drain retry, retirement grace). Taken wholesale at c2d6cf3 so the two merge as identical content rather than conflicting. The mock needs pendingRequestCount as a getter over the hoisted fakes, not an instance field, or the retirement case passes vacuously. Measured: 3 passed; reverting closeNow to drainRetry.reset() fails the drain-retry case only, as expected.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the prior review is a single test commit (c2d6cf3); production code is unchanged.
- Ratchet every armed pool timer, not just the drain retry (
relay-origin-pool-teardown.test.ts). Converted the single drain-retry case into anit.eachcensus of three armed states — live control rotation + lease refresh, drain retry after a director refusal, and a retirement grace after a cross-cell rotation — with agetTimerCount() > 0precondition so a case that armed nothing cannot pass vacuously. - Hoisted the mock control's
pendingRequestCountbehind a mutable getter so the cross-cell case can keep a retiring origin's grace timer armed.
I verified the new cases are load-bearing rather than theatre by mutating the source locally: deleting retirement.clear() leaves case 3 with 1 armed timer, and deleting rotation.cancel() fails all three. Full relay suite (22 files / 181 tests), oxlint on the four changed files, and typecheck:node all pass.
ℹ️ The ARMED_STATES docstring claims more coverage than the table provides
The comment on ARMED_STATES says it covers "every timer the pool and its broker can leave armed", but the optional RelayRegionRefresh timer is not among the cases — it is only constructed when measureRegionDecision is configured, which this test does not set. It is unref'd and regionRefresh.close() already clears it, so there is no leak here; the gap is only that a future break in regionRefresh?.close() would not be ratcheted by this test. Narrowing the docstring or adding a measureRegionDecision-bearing state would align the claim with the coverage.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ires Main gained a `typescript/consistent-type-assertions` scan while this branch sat behind it, so every `as` in the two test files this branch adds lands as a new finding. Each now names what earns it: the hoisted empty arrays cannot infer an element type before the mock class exists, and the two fixture casts name the one member the code under test actually calls.
…flags The diagnostic points at the property that opens the object literal, not at the `} as OrcaCloudAuthConfig` that closes it.
check-changed-code-quality.mjs lints the merge commit with current main's
rules against a merge base predating them, so every `as` in a file this PR
adds counts as new. Local oxlint stays green throughout; the gate reported 15.
Four of them were removable rather than suppressible: annotate the variable
(`const mode: { current: MobilePairingConnectionMode }`) instead of asserting
the literal. The rest carry a line-specific SAFETY rationale.
relay-origin-pool-teardown.test.ts is re-taken wholesale from #20258 at
b3783ad, which already carries its three directives, so the two still
merge as identical content.
Measured: gate exit 0 (casting and SAFETY-rationale both 0 new), tc exit 0,
relay+startup 83 files / 728 tests passed.

Two teardown defects in the relay origin pool and control origin. Both are live on
main; both have a failing-test-first proof and a mutation table. Based onmainat pinned SHAfa3d29fa11e38fba80d77b45dcbe401edd3ec53f.1.
RelayOriginPool.closeNow()never disarms the drain retry — live onmain, regression from a renamesrc/main/runtime/relay/relay-origin-pool.ts:104RelayDrainRetrySchedule.reset()only zeroes the backoff counter.cancel()is the verb that clears the armed timer (and then callsreset()itself). This call site usedcancel()until the retry schedule was generalised in #20106; the change toreset()came with that rename.This is not a latent nicety. Every pool close that happens with a drain retry armed leaves a non-unref'd
setTimeoutof up to five minutes on the main-process event loop, retaining the origin, its controls, the transport, and the whole closure graph. It fires after the quit and sign-out fences. Nothing reopens —handleDrain'sisCurrent()guard neuters the callback — but under director-outage reconnect churn one retained graph accumulates per closed pool, and the timer is exactly the class of post-fence survivor the quit work was written to eliminate.The two calls sit on adjacent lines. One clears its timer, the other only zeroes a counter. Read together, the omission is invisible, which is how it survived the rename.
Why the existing test could not catch it
backs off drain resolution failures without duplicate retries or post-close workasserts that no assign happens after close. TheisCurrent()guard satisfies that on its own, so the assertion stays green with the timer still armed. Two guards pinning different properties, one of them silently removable — that shape has now appeared repeatedly in this subsystem and is worth watching for in review.The new test asserts the property the old one could not: that no timer survives
closeNow().2.
RelayControlOrigin.rebind()leaks a live control whenactivate()throwssrc/main/runtime/relay/relay-control-origin.ts:117openControl()cleans up its own connect failure (controls.delete+closeNow). Theactivate()step that follows has no equivalent, so a throw there leaves the control it just opened both registered inthis.controlsand live on the wire.activate()throws oninvalid_relay_generationand — the reachable trigger — whenCloudRelayTransport.setGeneration()refuses a generation change while data sockets are attached (relay-transport.ts:99-105), i.e. a phone is attached and the cell could not resume the control generation.The drain path survives it:
resolveDrainTargetfalls back toactivateTarget, and closing the old origin sweeps the leaked control. The rotation path has no fresh-generation fallback —relay-control-rotation.tscatches and re-arms every 5–15s, leaking one live control websocket plus its silence-watchdog interval per attempt, until the lease expires and the cell drops the socket.Mutation table
Measured against pinned
mainfa3d29fa11e,vitest run --config config/vitest.config.ts.mainunmodifieddrainRetry.reset()→cancel()onlyrotation.cancel()removedThe last row is the one that makes the others mean anything: with
drainRetry.cancel()restored, deleting a different timer cancellation still fails the test. The assertion is "no timer survives close", not something incidental to the drain retry.Gates
vitest run --config config/vitest.config.ts src/main/runtime/relay/— 22 files, 179 tests, pass. Runner verified to exit non-zero on a deliberate failure (exit=1), so a green run is meaningful here.oxlinton all four changed files — exit 0 (it prints nothing on success; exit code is the only signal, and it was observed returning 1 on a bad invocation in the same session).tsc --noEmit -p config/tsconfig.node.json --composite false— exit 0, empty output.Please do not merge without a second read on the rotation-path fallback question below.
Follow-up, not fixed here
Fixing the leak bounds the damage but does not restore the session: the rotation path still retries a rebind that cannot succeed until the control lease expires, at which point the cell closes the socket and
onCloseroutes into the drain path, which does have the fresh-generation fallback. Giving rotation the same fallback is a behavioural change I could not justify from the code alone, so it is left out deliberately.The teardown ratchet now covers every armed timer
c2d6cf352a4. The original test pinned one state — a drain retry armed by a director refusal. That is the bug this PR fixes, but it is not the shape of the bug.Teardown here is a hand-maintained list of verbs against three helper classes that each spell "disarm" differently:
rotation.cancel(),drainRetry.cancel(),retirement.clear().drainRetry.reset()sat next torotation.cancel()looking symmetric while only zeroing a counter. Every behavioural test stayed green, because the guard at fire time meant nothing reopened — the leak was a retained timer with no behaviour at all. Counting timers is the only assertion that can see it.It is now an
it.eachtable over every timer the pool and its broker can leave armed, each reached the way production reaches it:openInitial) and the broker's lease refresh (armed byconnect)maybeClosedoes not retire it on the spotEach case asserts something was actually armed before
closeNow(), so a case that arms nothing cannot pass as0 === 0— which is exactly how the original leak stayed invisible.Mutation-verified per verb:
rotation.cancel()drainRetry.cancel()→reset()(the original bug)retirement.clear()Merge order: this must land before #19870
#19870 replaces
relay-drain-retry-schedule.tswithrelay-retry-schedule.tsand does not touchcloseNow, so landing it first would leavecloseNowback onreset()and recreate this trap.The fix is at the call site only.
closeNowmust callcancel(). Makingcancel()itself callreset()was considered and explicitly rejected: it would defeat the coordinator'sif (resetRetry)gate and flatten the auth backoff ladder. Leave the two verbs distinct and fix the caller.Resolved as of #19870's rebase — it carries
closeNowcallingcancel()and has taken this branch's ratchet wholesale.#19870 has since ported this file; when it rebases, it should take this branch's version wholesale rather than hand-merging. The mock needs
fakes.pendingRequestCounton the hoisted fakes andpendingRequestCountas a getter on the mockedRelayControlClientrather than an instance field — a hand-merge that keeps the field makes case 3 pass vacuously.