mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
2b19f21adab5907697ef76ce429bafcd2cfea9ec
797
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
729491597f | feat(desktop): measure relay regions and reconnect after idle cutover (#20106) | ||
|
|
a0799d8f1c |
fix(terminal): move the recovery ledger onto the tab row and gate it on observed outcome (#20025)
* fix(terminal): move the recovery ledger onto the tab row and gate it on outcome The recovery budget lived in module-level Maps keyed by tabId. Anything keyed outside the row needs a release path, and that release fired on every remount-driven pane disposal, so each remount erased the budget it had just consumed (crash b5cfc6ca). Put the ledger on TerminalTab and write it in the same set() as the generation bump: reading the budget is now reading the tab, so releasing it independently has no expression. Counting was also the wrong control. Every remount mounts a pane that captures a FRESH recovery epoch, so the epoch check can never refuse its request — recovery re-requested the exact action that had just failed with no evidence anything changed. Gate on an observed outcome instead, reusing the direct-SSH pane retry vocabulary (success | failed | timed-out | superseded) and its settle call sites: an unsettled attempt blocks the next one, and a settled failure refuses the same reason until a new trigger arrives (generation move, or the user's Retry). The 3-per-5min cap stays as a breadcrumb-emitting backstop, not the control. viewMode now also lands on the row from the local toggles, mirroring how pin already does it, so the chat-ownership guard reads one index instead of OR-ing two. * fix(terminal): persist the row's viewMode and keep both chat-ownership reads The narrowed chat-ownership guard read a field the session schema strips: terminalTabSchema never declared viewMode, so the terminal row lost it on every load while the unified tab kept it. After a restart the row read undefined and recovery would remount a chat-owned tab's hidden surface — the race #19745's guard exists to prevent. Declare viewMode on terminalTabSchema so the row is durable, and keep the disjunction rather than replacing it. The schema cannot retroactively add the field to sessions already on disk, so the first load after upgrade still has it only on the unified tab; and for a safety check over two partly-redundant sources, a hole in either index should err toward declining a heal. Also cover three structural guards that no test was holding: both remote ledger-carry paths (terminal-build, remote-workspace-session-merge) and the only success settle in the state machine, including its placement past the failure branches. * fix(terminal): settle a fresh spawn's outcome and prove the ownership guard across a reload spawn-left-pane-unbound was the one recovery reason with no success settle: its remount heals by spawning, not reattaching, so it reached none of the reattach settle points and left the attempt 'pending' for the full 31s bound. A fresh spawn that binds a PTY now reports it, the dual of the unbound settle that already reported failure. Two tests outside src/ still called remountTerminalTabForRecovery by its old boolean contract and broke CI; both are updated to the admission result. Also strips the client-local recovery ledger at the remote-workspace projection boundary, in the type as well as the destructure, so a future producer cannot put another machine's Date.now() on the wire. * fix(terminal): resolve the pane's tab row once for both epochs after the main merge #20034 replaced connect-pane-pty's inline tab resolution with findTerminalTabForPane, and this branch had rewritten the line below it to read the recovery epoch off the row that block used to bind. The merge was textually clean and semantically broken: `terminalTab` no longer existed, so typecheck failed and every test that connects a pane threw ReferenceError. Resolve the row once through the new helper and feed both epochs from it, which keeps #20034's refactor and this branch's reason for reading the row here — a second lookup would put another tabsByWorktree scan on the connect path. captureTabRecoveryGeneration is narrowed to the one field it reads so the helper's record type can carry it. |
||
|
|
20c56249d5 |
fix(terminal): keep a deliberately slept workspace cold until it is woken (#20075)
* fix(terminal): keep a deliberately slept workspace cold until it is woken Sleeping a workspace kills its PTYs but keeps its panes mounted and keeps each tab's session id as a wake hint. Any later remount of those panes (recovery, parking, portals) reattached that dead id, and the daemon's create-or-attach spawned a fresh shell, so slept workspaces revived on their own (#10205). The existing sleep-intent marker now outlives teardown and gates the deferred connect itself, so both the reattach and fresh-spawn arms stay cold. It is released by activating the workspace, by any PTY binding to one of its tabs (CLI, automation, client wake), and by purge. A queued startup still connects. Reproduces the community root cause from gatsby74 in #13343; the regression e2e remounts a slept hidden pane and fails on main. Co-authored-by: gatsby74 <gatsby74@users.noreply.github.com> Co-authored-by: mmarabel <mmarabel@users.noreply.github.com> * fix(terminal): let a slept pane wait for its wake instead of latching cold A pane whose connect ran while its workspace was slept used to mark itself connected and stop; nothing re-armed it, so a wake that produced a live PTY before the user clicked (CLI create, background agent resume, split panes) left panes stranded. The connect now waits on the sleep marker and resumes when the marker clears, and a torn-down pane drops its listener. Tabs created with a live PTY clear the marker too, the sleep flow marks each workspace only when its own teardown starts, and purge forgets the marker without waking anything. * fix(terminal): wake a waiting pane once, in its remounted generation Activation clears the sleep marker after the set() that bumps dead tabs' generations, and the waiting pane only resumes its connect when its tab generation is still current. Otherwise the stale pane and its remounted successor both reattached the same session id on a deliberate wake. * fix(terminal): resolve the waiting pane's tab by either id and re-arm after wake The wake listener looked the tab up by the pane's render id, which can be a unified id whose terminal tab lives under entityId, so the generation check declined forever for those panes. Mount, fresh spawn, and the wake listener now share one live resolver. The wait flag resets when the listener fires so a second sleep can hold the pane again, listener dispatch is guarded, folder activation clears after its own set(), and the sleep flow re-asserts the marker after each teardown while releasing a workspace the user activated meanwhile. * fix(terminal): ignore PTY binds that land inside the sleep teardown window A spawn resolving while shutdown was still awaiting the host bound a PTY and cleared the marker, waking every waiting pane mid-sleep; re-marking afterwards could not un-connect them. The sleep flow now scopes each teardown so binds in that window are not wakes. The e2e asserts a deliberate wake yields exactly one PTY, and the dispose test proves the listener is gone. --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: mmarabel <mmarabel@users.noreply.github.com> |
||
|
|
22d12388a5 | fix(pi): load extension providers for source control generation (#20070) | ||
|
|
78e985cd99 |
fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) (#16631)
* fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) The managed pi/omp/prime-agent status extension suppressed itself whenever ORCA_PI_STATUS_OWNED held a PID other than its own, with no check that the owner still existed. A restart leaves the previous owner's PID in the inherited env, so every later load returned early and the pane stopped reporting status permanently. Probe the owner before suppressing. Only ESRCH proves it is gone; any other probe result keeps suppression so a live foreign owner still cannot double-report. This mirrors the tri-state in main/agent-hooks/managed-hook-owner-identity.ts, which the extension cannot import because it loads inside the pi/omp runtime with no Orca deps. Also extracts the generated-source test harness into its own module so the suite stays under the max-lines limit. * fix(pi): validate inherited status owner pid markers --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
5fa62feda7 |
perf(terminal): mount only the visible pane on a worktree switch (#20034)
* perf(terminal): mount only the visible pane on a worktree switch Activating a worktree mounted a TerminalPane for every tab it holds, not just the one on screen. Cold-activation deferral existed for this but engaged only past four deferrable hidden tabs, which exempted the 2-5 tab worktrees that make up almost every real switch. Deferral now engages for any deferrable hidden tab, and the siblings it skips are admitted one per idle frame after the reveal, capped at the population the old threshold would have mounted eagerly. Steady-state pane, WebGL-context and heap population are therefore unchanged; only the frame the mounts land on moved. * fix(terminal): judge admission eligibility on the largest deferred set seen Review found the launch worktree never warms up: it is restored active before hydration opens the startup gate, so admission read an empty deferred set, cached ineligible, and never recomputed once the real plan landed. Judge on the high-water mark instead - an over-cap worktree still stays ineligible as its set drains, but a later plan is seen. Also from review: the e2e WebGL counter read getPanes(), which returns a public projection with no webglAddon field, so it was always 0; read getRenderingDiagnostics() instead. Filler worktrees now clean up on failure (testRepoPath is worker-scoped), and the restore metric is named for what it measures rather than implying a pixel assertion. * test(e2e): wait for the reveal to restore, and scope the latency budget off CI CI failed with 'revealed terminal never restored its content': the harness sampled a fixed 4s window, which a shared runner can outlast, so a slow restore was recorded as no restore. Poll for the restore instead. Also stop asserting a latency budget on CI. Shared runners cannot hold a threshold; the structural invariants (one pane mounted by the switch, warm set restored) are exact and stay asserted everywhere. |
||
|
|
3b82d8de64 |
fix(runtime): let connections own host status recovery (#20003)
* fix(runtime): let connections own host status recovery Verify runtime status after authenticated connection recovery and publish ordered snapshots to desktop and browser viewers. Consolidate failed-status retries in the connection owner and remove renderer retry/diagnostics merging. Adapt sidebar host-state derivation and regression coverage from Omar Shahine's original fix in https://github.com/stablyai/orca/pull/19163. Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> * fix(runtime): show blocked hosts honestly and remove obsolete status options * fix(runtime): preserve timeout guidance and update IPC test fixtures * fix(runtime): preserve status evidence and address review gaps * test(sidebar): assert workspace host icons dimming and recovery tooltips * fix(palette): require available hosts before adding implicit badges * fix: retain disconnected host snapshots for new renderers --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> |
||
|
|
1798786d4e |
perf(native-chat): mount only the transcript rows near the viewport (#19869)
* refactor(native-chat): share one row-content derivation between row and list Windowing needs the list and the row to agree on which messages draw nothing: a row the list counts but the row declines to render would reserve estimated height for an empty slot. Extracts the block derivation out of NativeChatMessageRow into a module cached on the block array, so a streaming turn pays for it once per revision rather than once per consumer. * refactor(native-chat): keep an opened tool run open past its row's lifetime A tool run, tool line or diff card the reader opened is state they created, but it lives in the component's own `useState`. That is fine while every row is mounted forever. It stops being fine the moment rows can be unmounted: the run silently re-collapses behind the reader's back. Rows now read their disclosure from a transcript-level map when one is provided and fall back to their own state when they are rendered standalone. The controls that re-sync a run — the toolbar's expand-all, a turn's disclosure, a diff reveal — are folded into the key the choice is remembered under, so a control flip reads as "nothing recorded yet" and the new default stands without a mid-render write to a map an ancestor owns. `ToolLine` moves to its own file; the run was over the line cap with it. * perf(native-chat): mount only the transcript rows near the viewport A settled transcript mounts every row it has ever loaded, so the cost of opening a conversation grows with its length even though only a screenful is legible. Rows near the viewport are now the only ones in the document; the rest are reserved as estimated height and measured when they arrive. Four things had to change for that to be safe: - `zoom` moves from the transcript column onto the scroll container. Item measurements are in the zoomed content's pixels while `scrollTop` is not, so with the two split across the boundary the window's arithmetic was off by exactly the font scale — correct at the top of a transcript and blank deep inside it. The column's padding moves to a new inner element to keep the layout it had. This does mean the scrollbar itself zooms with the text. - The three siblings that made up a row — the message, the turn status, the turn's diff rollup — move into one wrapper that carries the spacing they used to take from the column. The spacing between rows is the window's `gap`, never the height estimate, which would otherwise be counted twice. - Messages that draw nothing no longer take a slot. Counted but undrawn, each one would reserve estimated height for a row that never appears. - Paging in older history is driven by scroll events alone. Every row that resolves its real height moves the content and re-fires the size observers, so the old "am I near the top?" test would have asked for another page once per measurement. It now also requires the view to have moved upwards and requires new items since the last request. Anchoring is the virtualizer's: `anchorTo: 'end'` re-resolves the row at the current offset across a count change, which replaces the hand-rolled prepend anchor, and `followOnAppend` keeps a reader at the bottom pinned there. The document-level bottom pin stays, because the typing indicator, the activity line and the column's end padding all live past the last row. Revealing a diff from a turn rollup can target a row that isn't mounted, so that row is pinned into the window and the card still reports its own position — a turn that touched four files lands on the one that was asked for. * fix(native-chat): let a pinned row reach the mounted window Two faults the windowing tests turned up, plus the handles they needed. The virtualizer memoizes its mounted index list on the range extractor's identity. Holding that identity stable — which is right for the measurement memo, and was the reason it was written that way — meant a row pinned after the fact was never picked up: revealing a diff in a row the window had left behind pointed at a row that stayed unmounted. The extractor now changes identity with the pinned set, which is not a dependency of the measurement memo, so nothing expensive is rebuilt. The offset a row sits at is read off the `offsetParent` chain, with a rect-based fallback for the case where there is none. Using that fallback for the window's own scroll margin was wrong in kind: with no layout to measure, it returns the scroll position itself, so the margin tracked the offset and the window sat at the top of the transcript wherever the reader scrolled. The margin now takes the offset chain or nothing; the fallback stays where it belongs, on the reveal. The scroll root and the window's spacer are named, so measurement can find the scroll root without depending on which utility class makes it scroll, and so a test can tell a window from a whole transcript. * test(native-chat): cover the windowed transcript, and prove the window engaged The integration harness stubs `offsetHeight` — on the scroll root and on every row — because that is what the virtualizer measures with, and a DOM without layout answers zero to all of it. Rows report the height their own estimate predicted, which keeps the reserved totals exact no matter which rows have been mounted long enough to be measured. Every case reads the window through one helper that refuses to pass when there is no window. Without that, raising the usability gate would send all of them down the whole-transcript path, where "fewer rows mounted than messages" is false but every other assertion still holds — and they would go on reporting green while covering nothing. Reserved height is asserted as an exact total rather than "greater than zero", which a degenerate empty window also satisfies, and the mounted range is asserted to bracket the offset rather than merely to be smaller than the transcript. Covered: the window mounts a subset and moves with the reader; the newest row and a reveal's target stay mounted from outside it; an opened tool run is still open when its row comes back; a message that draws nothing takes no slot; and the scroll root with no usable height still renders every row as a direct child of the transcript column. What the environment cannot show is stated where it matters rather than faked: its ResizeObserver never fires and a scroll assignment emits no event, so measurement settling, the bottom pin under a streaming turn, prepend anchoring and smooth scrolling are covered as pure decisions — height estimation, the pinned set, range extraction, and whether a position should page in older history — and left to a real renderer as behaviour. * docs(native-chat): say that one offset path does read rects * test(native-chat): pin the window against a row that grows in place Whole-message appends were covered; a row being replaced by a taller version of itself — what a streaming reply is — was not. The existing windowing harness gains two things it needs to see that: a scroll root with a real document (a height, a viewport, and a scrollTop that clamps), and a resize observer that delivers when a target's height actually changed, since happy-dom's never fires and nothing re-measures without it. Frame by frame, while one row grows from 24px to 6358px: the view stays 0px from the bottom, the row stays mounted, and the reserved total tracks the measurement rather than the estimate. A reader who scrolls up mid growth keeps the exact offset they chose for the rest of it. * test(native-chat): guard history prepend anchoring * test(native-chat): strengthen prepend anchor contract * fix(native-chat): preserve provider tool call identity * fix(native-chat): harden transcript windowing lifecycle * test(native-chat): install virtualizer viewport for turn timing * fix(native-chat): reject blank tool call identities --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
fb9ba4b681 |
fix(editor): make markdown images inline so a paragraph stays schema-valid (#19746)
* fix(editor): make markdown images inline so a paragraph stays schema-valid Image was registered as a block node while paragraph is content:'inline*', but the markdown pipeline nests an inline image as a paragraph child. Schema.nodeFromJSON does not validate content, so the editor built a schema-invalid document that rendered fine and threw on the first step that reassembled the paragraph - i.e. on the user's next keystroke. Report 0e46c048 (1.4.198, macOS): RangeError "Invalid content for node paragraph" from checkContent via Node.replace, tearing down the editor.rich-markdown boundary. Register Image as inline and override paragraph's parseMarkdown so a lone image is not hoisted out of its paragraph. Also fixes the same crash class reachable through details/summary. Markdown output is byte-identical. * fix(editor): keep a fenced code block intact when an image is inserted into it Making the image node inline meant it could no longer be fitted into codeBlock (content:'text*', marks:''), so inserting one with the cursor inside a fence made ProseMirror close the block at the insertion point: the remaining code escaped as plain prose and the language attribute was lost, and autosave wrote that markdown to the user's file. The pre-fix block image split the fence into two intact blocks instead. Resolve the insert content against the target position: when an inline image cannot be fitted where the caret sits, wrap it in a paragraph so ProseMirror splits the block and both halves keep their ``` fencing and language. Prose insertion is unchanged. Every production insert path now shares that resolution - the toolbar picker, the slash command and the clipboard-screenshot paste through insertRichMarkdownImageFromPath, plus the GitHub/GitLab composer's image-URL insert - each with a regression test. Also guard the unchecked cast of Paragraph.config.parseMarkdown: a Tiptap upgrade that drops the field would otherwise turn every paragraph parse into a TypeError and take the whole editor down, instead of degrading to parseInline. Four of the new round-trip cases asserted only on getMarkdown(), which walks the document without running NodeType.checkContent and so emits byte-identical output from a schema-invalid document - they passed on the pre-fix code. roundTripMarkdown now runs doc.check(), the list-item and table-cell case performs a real edit, and the standalone-image case types beside the image. All twelve cases now fail on the merge-base. Adds an Electron e2e spec driving the real renderer: a paragraph image and a toggle-summary image each survive a keystroke, and Bold over a selection spanning the image keeps it. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
027acb4efa |
fix(native-chat): settle a structured send on admission, not on the provider echo (#19863)
* fix(native-chat): settle a structured send on admission, not on the provider echo Sending a message in structured native chat raised "Message delivery is unconfirmed." with a Retry button on a message that had in fact been delivered. Measured across 14 days of local journals: 44 of 173 delivered sends (25.4%) tripped it. The dispatch path wrote the message to the provider, then waited a fixed 10s for the provider to echo the message's uuid back. That echo is emitted when the provider STARTS the turn, so a message queued behind a running turn cannot be echoed until that turn ends. Echo latency is bounded by the previous turn's duration, which is unbounded -- one send took 105 minutes. The 10s constant sat at the p75 of real echo latency, with the slowest clean send at 9.76s, a margin of 0.24s. No constant can work: the wait was measuring the wrong event. The false banner was not cosmetic. It invited a Retry, and Retry bypassed the operation ledger to redeliver. One message reached the model five times through that path. Dispatch now returns as soon as the transport write completes and writes no dispatch row; the submission stays `pending`, a neutral state, and the provider's echo settles it `accepted` through the late-settlement channel whenever the turn ahead of it ends. Delivery doubt is reachable only from process facts -- a refused write, a dead child, a dead host -- never from elapsed time. Retry re-delivers only where the recorded reason proves the message never reached the provider. The list is deliberately fail-closed: refusing a legitimate retry costs the user a re-type, while allowing an illegitimate one sends the model a second copy of their message. A refused entry now leaves the outbox with an explicit notice instead of parking at the head, where it would have wedged every message queued behind it. The send-response classification moves to a pure module beside the existing outbox reconciler, so both writers of an entry's state now live together and the decision is unit-testable rather than reachable only through the hook. Scope and known gaps: - Codex carries the same 10s stopwatch. It has no late-settlement channel, matches waiters by queue order rather than identity, and has no waiter lifecycle at all, so there was no safe subset to land here. A marker constant records the debt and deletes itself when that lands. - A message refused re-delivery loses its standing delivery notice and leaves only a transient error line. A passive "waiting to be accepted" affordance is the follow-up. - The restart reconciler that would decide a dead child or a dead host on evidence rather than refusing them is fully written and has never had a production caller. Wiring it is the next change, and it removes the re-type cost above. * fix(native-chat): harden structured dispatch settlement * fix(native-chat): preserve dispatch recovery evidence * fix(native-chat): preserve pending send compatibility * fix(native-chat): satisfy native import audit * fix(native-chat): bound legacy send settlement --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
fb85f88d64 |
fix(browser): restore the Chrome-shaped browser identity (STA-7147) (#19927)
* fix(browser): restore the Chrome-shaped browser identity (STA-7147) #18749 replaced every browser partition's Chrome-shaped UA with Electron's stock one, so since v1.4.198 the embedded browser announces itself on every non-Google host as: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.4.198 Chrome/150.0.7871.224 Electron/43.4.1 Safari/537.36 No browser sends that. Sites that re-check the identity holding a session reject it: users report being signed out of x.com, LinkedIn and "most websites," and at least one was signed out of LinkedIn in their own Chrome and met LinkedIn's "suspicious activity" SMS check -- server-side revocation, which reaches beyond our app. The repo already documented the mechanism in browser-google-auth-ua.ts: copied-in cookies "sent under a UA that doesn't match a real first-party browser get flagged by anti-fraud." That is why the Google auth-host switch exists; #18749 kept it for accounts.google.com and handed every other host an Electron identity. Restore the pre-#18749 session identity: strip the Electron and app tokens, and rewrite sec-ch-ua to match. Nothing in the cookie-import write path changed -- it never did; cookies were always written correctly and servers were refusing them. Deliberately KEPT from #18749, all independent of the UA: - anti-detection.ts stays deleted. Its premises were measured false on Electron 43 and its overrides are themselves published bot signatures. - No Runtime.enable into cross-origin iframes (the documented Cloudflare CDP tell). - No unconditional CDP debugger attach on every browsing guest. Known tradeoff, measured: this re-opens #13822. On the unmerged predecessor branch brennan/sta-3905-cloudflare-ua, commit 9f0a4772fe recorded the stock UA clearing dash.cloudflare.com 5/5 while every rewritten variant failed 12/12, and noted that adding client hints does not rescue it. So Cloudflare-gated sites will show verification failures again until a coherent-identity fix lands. That is a bounded, in-app annoyance; session revocation damages users' real accounts. A CDP Emulation.setUserAgentOverride with full userAgentMetadata -- which drives navigator.userAgentData as well as the headers, and was never tested -- is the candidate that could satisfy both, and is being measured separately. Tests: the real-Electron wire-identity test now asserts the stripped identity on ordinary hosts and Firefox on Google auth hosts. Ablation-verified: neutering cleanElectronUserAgent turns it red on the Electron-token assertion. Its fixture also gained an app name -- without one the raw UA carried no app token, so the Orca/x.y.z half of the cleaner was never exercised. * fix(browser): finish the identity revert in the files CI caught browser-session-registry.persistence.test.ts still asserted #18749's behaviour ("keeps the stock UA", "keeps the engine UA"), so the shipped code and its test disagreed. Caught by CI shard 4/8, not locally: I reverted four test files and went to typecheck without re-running the browser suite. Also restores the accurate wording that #18749 generalised away, now that the behaviour it described is back: - browser-google-auth-ua.ts: names the Electron/Chrome-shaped UA again as what anti-fraud flags, which is the reason the auth-host switch exists at all. - docs/browser/profiles.mdx: documents the cleaned Chrome UA default and the --no-ua-spoof escape hatch, which is real again. - tests/tools/google-signin-ua-probe.cjs: comments name the live handler. Deliberately left at #18749's version, because those changes stay correct with anti-detection.ts deleted: - browser-manager-viewport.ts: its comment no longer cites the retired addScriptToEvaluateOnNewDocument injection. - browser-webauthn-profile-delete.test.ts: its added webRequest mock is REQUIRED by the restored setupClientHintsOverride, so reverting it would break the test. * fix(browser): keep restored UA hints browser-owned --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
33436c30d8 |
refactor(native-chat): unify agent session launch and open drafts in structured chat (#19681)
* wip(native-chat): first-pass draft routing into structured chat (to be reworked)
* refactor(native-chat): gather agent launch route inputs in one builder
Every launch entrypoint assembled the route resolver's inputs by hand and
they disagreed: only three of seven passed the project runtime blocker, so
a WSL-pinned project was refused structured chat from the tab bar but
admitted from the create dialogs. buildAgentLaunchRouteInput is now the
one place that gathers host, capabilities, workspace kind, project runtime
and TUI customization, and works for workspaces that do not exist yet.
Also deletes the dead draft-prompt blocker from the shared resolver; the
renderer stopped passing it and the main process never did.
* refactor(native-chat): share one structured launch settle loop
Five entrypoints copied the same loop around startStructuredAgentLaunch:
start, claim a refusal fallback, await, branch on refusal or unknown. The
copies drifted: direct work-item and full create reported an unexpected
launch error as success, and resume handled neither refusal nor unknown.
settleStructuredAgentLaunch now owns that loop and returns one settlement
(structured, refused-then-legacy, cancelled, visibility-unknown, failed).
Direct work-item, full create, folder workspace, both onboarding folder
paths and vault resume consume it; each keeps only its own legacy fallback.
Resume deliberately has no fallback. Unknown outcomes release the caller
uniformly so a stale fallback closure cannot fire on a later reconcile.
* refactor(native-chat): route the new-tab launcher through the shared settle loop
The new-tab launcher fired its refusal fallback and forgot it: nobody
learned whether the terminal fallback ran, and a visibility-unknown outcome
was never surfaced. Its structured branch now runs through
settleStructuredAgentLaunch with the terminal launch as the legacy fallback.
launchAgentInNewTab stays synchronous; the result gains a structuredSettlement
promise, and promptDeliveryResult keeps following the terminal fallback's
delivery on refusal as it did through the callers bridge before.
* refactor(native-chat): one legacy prompt delivery path and one trust preflight
The direct work-item flow kept its own seed-and-paste copy of the legacy
prompt delivery; it now uses deliverLaunchPromptToAgentTab with its own
timeout notice supplied as a callback. Three private copies of the trust
preflight (session continuation, worktree creation, folder workspace) fold
onto preflightAgentTrust. The direct work-item pre-launch mark keeps its own
entry because it differs in timing, not mechanism.
* refactor(native-chat): run quick create through the shared settle loop
Quick create was the last entrypoint driving the launch handle itself,
because its cancel lifecycle is real: when the creation is abandoned the
structured launch must be cancelled immediately so a staged prompt never
reaches the provider. The shared loop now takes a cancellation hook with an
eager subscription plus a post-await check; it cancels the launch once,
unsubscribes on settle, and reports cancelled without running the fallback.
Quick create keeps its two-branch legacy fallback and retire-on-late-cancel.
Also updates the surface-caller census for the onboarding launch module
that step 2 introduced.
* fix(native-chat): open editable drafts in structured chat for eligible local Codex launches
Route order asked the default-view-mode question first, and that decider
applies the terminal mirror gate (a TUI cannot clear more than forty lines
of prefilled draft), so a PR body over forty lines reached the plain
terminal before structured eligibility was checked. Structured eligibility
now comes first; the mirror gate applies only on the legacy branch.
The structured draft seed writes the launch-draft store directly with no
mirror gate, since a structured session has no terminal copy to fall back
on. Closing a settled structured tab clears an unadopted seed. The
structured session treats idle and loading as unsettled so the adoption
hook takes its baseline from the loaded transcript. Each caller passes one
delivery-mode value to both the route builder and the settle loop.
The structured session component test is split with a shared harness so
it stays under the test file line cap.
* test(native-chat): make the structured session test harness type-portable
* fix(native-chat): close review gaps in the shared launch settle loop
- Claim a refusal fallback only when the caller supplies one, so vault
resume no longer reports a terminal fallback it never opened.
- A failed or cancelled direct work-item launch returns no tab id, so the
caller never pastes the prompt into a setup shell.
- Terminal fork activates with providesInitialSurface for structured
launches and gates its toast on the settlement; the draft blocker
deletion made fork route structured too.
- A failed launch clears its draft seed. The failure toast moves to its own
module to keep the launch-state file under the line cap.
- Ratchet for settle-loop callers; cancel-during-fallback documented.
- Restore the local agent label lookup that the pane-agent identity
inventory expects instead of the inventoried helper.
* fix(native-chat): resolve the agent label through one module
* fix(terminal-pane): keep the fork dialog from reopening a created worktree
A failed or unknown structured settlement returned false after the fork
worktree already existed, so the dialog stayed open and a second click
created another worktree. Unknown now closes the dialog (the launch badge
already reports it); failed copies the context the way a null launch does.
* chore: restore pnpm-lock.yaml to main (local pnpm rewrite slipped into a commit)
* test(native-chat): stop asserting the deleted draft feasibility input
The routing-authority test expected the shared predicate to receive
isDraftPrompt; delivery mode is prompt metadata and never reaches
feasibility now, so assert its absence instead.
* refactor(native-chat): decide every agent launch route in one planner
The route was still resolved at seven callers, each also calling the settle
loop; two census tests only stopped an eighth. planAgentSessionLaunch is now
the one production caller of the resolver and its launch() the one caller of
the settle loop, and both censuses pin exactly that file.
The funnel is two-phase because three sites need the route before the
workspace exists and quick create persists its request for recovery: a plan
exposes route before creation and launches with the created worktree id;
a persisted quick-create request carries the verdict as data and re-enters
through adoptAgentSessionLaunchVerdict without re-resolving. Delivery mode
is fixed on the request once, so route and launch cannot disagree.
* test(native-chat): pin the two adopters of a planned launch verdict
* fix(native-chat): answer route readability from the repo when the worktree row is absent
The planner's transcript-readability input dropped the repo-level connection
fallback the direct work-item path still computes for its startup payload, so a
route planned in the window right after workspace creation saw `undefined` —
which reads as "not locally readable" — and downgraded grok/omp launches from
native chat to a raw terminal. Only `undefined` ("cannot determine the host")
now defers to the repo; a resolved `null` stays the local answer.
* refactor(native-chat): answer structured feasibility with a query, not a launch plan
Every rendered AI Vault row built a whole launch plan — execution-host lookup,
project-runtime resolution, capability read, plus a plan object and a launch
closure it threw away — to read one boolean off it. Feasibility and a launch
decision are different operations, so the planner now exports the predicate for
the first and keeps the plan for the second, and the census pins the query's
callers separately. Settings arrive by argument, which makes the AI Vault
callback's dependency on them real rather than a comment the linter contradicts.
The plan's `explicitStructured` branch had that gate as its only caller and goes
with it; the vault's launch already re-enters on an adopted verdict.
* refactor(terminal-pane): fold the fork's trust preflight onto the canonical one
`preflightForkAgentTrust` was a behavioural duplicate of `preflightAgentTrust`,
whose signature now accepts a nullable agent and workspace path and so is a
drop-in replacement. Its file is left holding only the launch-platform resolver
— which is not a duplicate, since it returns an override rather than a default —
so the file is renamed for what it now contains.
* refactor(native-chat): cancel a structured launch through an AbortSignal
The settle loop's launch cancellation re-derived the standard poll-plus-eager-
event primitive that `AbortSignal` already is, so it now takes one. The eager
semantics are unchanged: the loop still cancels on the abort event rather than
only polling after awaits, so a staged prompt is discarded before it reaches the
provider, and it drops its listener on settle instead of leaving the signal
holding the closure. Quick create owns the controller and bridges its store
subscription to it.
A cancel that lands after the refusal fallback already opened a terminal now
carries that surface on the settlement. It is the fallback's tab that exists, so
reporting the pre-launch one handed the caller a workspace with no agent in it.
* fix(native-chat): tighten quick create's structured launch settle path
Four things the launch path got wrong once the settle loop owned the flow:
- The abandoned-creation check now runs before the first-message rename flag is
written, so a creation being torn down is no longer marked for a rename that
will never happen (the order the pre-planner code had).
- A cancel that arrives after the refusal fallback opened its terminal reports
that terminal rather than the pre-launch tab.
- `plan.launch` is called outside the caller's try, and nothing awaits that
caller, so a throw there would strand the creation panel. It is now caught and
reported the way a failed launch already is.
- The launch route is a required argument instead of defaulting to
`terminal-tui`, which would have silently reported success with no surface
opened. Both callers already gate on the structured route.
* fix(native-chat): give one launch identity one prompt delivery mode
A caller joining a pending launch computed its outbox text from its own delivery
mode, so an auto-submit caller landing on a draft launch enqueued text the first
caller's seed was already showing in the composer: the user saw it and it was
sent. The mode is now fixed by the caller that opened the launch, and a joiner
delivers its text that way.
Seeding also moved to where the coalesce decision is made, so a launch whose
callers already settled as refused is not given a fresh draft — the refusal path
early-returns, so nothing would ever clear it and it would outlive every tab.
* fix(work-item): report a failed structured launch as a failed direct launch
`launchWorkItemDirect` returned true unconditionally, so a structured launch
that opened no surface still read as a started workspace. Callers hang
irreversible follow-up work off that boolean — the fix-checks dialog fires
`onLaunched` on it, which is documented as the home for host writes — so a
launch with no agent tab now reports false, matching what full create does.
The settle result says so explicitly rather than leaving callers to infer it
from a null tab id, which `notLaunched` also produces.
* test(session-tabs): pin the id a first structured publication is minted under
The launch draft seed is keyed on `structuredAgentSessionTabId(sessionId)`
before the tab exists, while the mirror mints ids with collision avoidance that
can append a `:history-N` suffix. The two agree today only because a fresh
session's base id is unique. Pin that where the id is actually minted, with the
collision arm alongside it so the divergence the seed depends on staying away is
visible rather than assumed.
* test(native-chat): pin the route connection fallback on the un-mocked resolver
The suite that covers the builder stages `getConnectionIdFromState`, so it can
characterize the fallback but cannot catch a defect that lives in owner
resolution itself. This one runs the real resolution over real store rows: two
repos publishing the same worktree id on different hosts, which is the
documented case where the owner cannot be named and `undefined` is returned.
Red with both fix files at the previous head, green with them.
Reverts the two caller pins added to the route census — the feasibility
predicate is exported from the planner, which the census already permits, so it
passes unedited and needs no permit clause.
* fix(native-chat): keep the structured launch's own agent eligibility check
Quick create's structured launch narrowed its guard to a bare `agent` presence
check, so a creation carrying an agent that cannot hold a structured session
reported itself cancelled once dismissed, where it previously reported that it
had done nothing. Unreachable through both callers today, but it is the last
local eligibility check in a module that otherwise trusts its callers for the
route, so it is restored rather than left to the required-route typing — which
says nothing about the agent.
Also corrects two comments that called the quick-create request "persisted".
It lives in renderer session memory and dies with the renderer; calling it
persisted made the plan/adopt split read as restart recovery, when what it
actually buys is a route decided before the worktree exists.
* fix(native-chat): keep the structured feasibility query typecheck-clean
The query threaded its narrow settings through the store, but the route
store's settings must satisfy the full GlobalSettings that two of its
resolvers require, so the narrow copy never fit. Ride the named settings
on the built input instead: the caller still names them, so a React memo
still depends on them, and no store-shaped object is needed.
Also give the launch state its delivery mode unconditionally; the key is
required, and a conditional spread makes it optional under
exactOptionalPropertyTypes.
* docs(native-chat): name the feasibility query's one remaining settings asymmetry
The builder reads launch customization off the store while the routing gate
reads the named settings, so one answer has two settings sources. It cannot
diverge with the single caller passing the object the store already holds, but a
PR about removing split sources should not leave that unstated.
* fix(native-chat): keep a coalesced joiner's draft unsent
joinLaunchDelivery stripped the joiner's delivery mode when the launch it
joined had established none, and an absent mode reads as submit. A joiner
that asked for a draft therefore had its text sent — the send-without-
consent this PR exists to prevent. Fall back to the joiner's own mode only
when nothing was established, so the first caller still wins otherwise.
* chore: re-trigger CI
GitHub created no workflow run for
|
||
|
|
2626e2eca4 |
Make the structured turn lifecycle row durable so completed durations survive (#19695)
* Make the structured turn lifecycle row durable so completed durations survive A structured-chat turn used to end by tombstoning its running lifecycle item, which threw away the only durable record of when the turn ended. Completed "Worked for" labels therefore depended on the renderer having observed the turn finish, and vanished on reopen. The lifecycle item is now revised in place, never tombstoned: - running, with startedAt, at the provider's turn start - completed or interrupted, with completedAt, at the provider's terminal frame, a user stop, or a child exit the host observed - unverifiable, with no end, when a cold acquire finds a running row from a generation whose exit nobody observed Both timestamps are the execution host's clock at receipt, captured before the deferred sink, so the completed value is identical on every client and needs no client clock. Codex history restore uses the provider's own second-granular endpoints for turns that predate this change. Desktop and mobile read settled durations off the journal through one shared selector, and anchor the live counter on the host start with the client's local receipt so a skewed client clock never leaks into the label. Locally observed durations remain the fallback for hosts that still tombstone. Timestamps live inside the existing turnLifecycle field, which old clients strip, and every working-state consumer keys on state === 'running', so no capability negotiation is needed. * native-chat: avoid stale working status on settled turns * test: align settled turn status expectations * Name settled lifecycle rows by their terminal state An interrupted or unverifiable turn must not read as completed for any consumer that renders status text raw. One shared helper builds the text for both providers from the lifecycle state. * test: deduplicate turn lifecycle suites Each behavior keeps one test; duplicated harnesses and restated cases go. * Key lifecycle rows to their user item and record the provider's measured duration A lifecycle row now names the user item that opened the turn by its provider key, so clients attribute timing explicitly and fall back to journal order only for rows from older hosts. A provider-initiated turn with no prompt can no longer claim the previous prompt's duration. When the provider measures the turn itself (Codex turn.durationMs, Claude result.duration_ms) the terminal row records it and clients prefer it over the host interval, so a turn shows the same number live and after a history restore. Host receipt times remain the live-counter anchor and the fallback. * Record a turn as a first-class journal item The turn record is now its own item kind rather than a status row carrying a lifecycle field: no text to misuse, and the fold matches the durable turn record other systems keep. Rows that carry it are stamped journal schema v3; every other row stays v2, so an older host keeps reading them and latches read-only at the first v3 row instead of truncating the epoch. Clients that predate the item would paint an unknown kind as a text bubble, so the host publishes the legacy status form to any client that does not advertise agent-session.turn-item.v1, through the same per-client seam background tasks use. The downgrade is transitional and goes once no supported release lacks the capability. The shared projection now renders unknown item kinds as nothing, so later kinds need no gate. One shared reader handles both forms for old journals and old hosts. * Preserve observed turn end across settlement retries * Retain turn attribution for loaded chat history * Preserve Codex exit receipt across close retries * Register completed turn duration reliability gate * Keep earlier turns through a Codex rewind and count a mid-turn attach from the real start Findings from an independent adversarial review of the typed turn record: - A Codex rewind adopted the provider's item list as the new epoch, and the provider never returns the host's own turn rows, so every duration before the rewind point vanished. The host's turn rows are now spliced back beside the item each followed, and recovery no longer expects the provider to prove rows it never owned. - The epoch row was stamped with the current schema version, so an older host latched read-only at row 1 of every new session, defeating the mixed version design. It carries no body and stays at v2; a stored-row test now reads SQLite directly, because the reader upcasts every row on read. - A send Codex folds into a running turn shares the opening prompt's provider key, and the alias map credited the duration to the later prompt. The earliest submission naming a key now wins. - The live counter anchored on first sight, so a client attaching mid-turn counted from zero. Published frames now carry the host's clock, the reducer keeps the last sample with its local receipt time, and both clients anchor on how long the host says the turn has run. * Correct turn duration gate assertion reference * Respect authoritative unknown native chat duration * Preserve unverifiable timing across older host upgrade * Record final completed turn duration reliability evidence * Fix the CI failures the merge left behind - A merged import list named the same module twice, which the native code quality plugin fails on. - A running turn is now reported by the host with no duration, so the settled map carries an explicit null for it; the hook test still expected the entry to be absent. - main gave the older-page action a cursor with a head-trim guard, so the retention test's epoch-only action no longer typechecks; it now passes an unbounded sequence, which is what the old shape meant. - The roster comparator moved into the extracted module, leaving its import unused in the reducer. * Split two files back under the line cap after the merge Merging main put both one effective line over 300, and the cap forbids a disable or a shave. The wire module's refusal vocabulary moves to its own file and is re-exported, so its consumers are untouched; the host's four thin mutation delegates move next to the functions they call. * Advertise the turn-item capability on every client transport Local IPC and mobile advertised it; the remote and web transports did not, so a desktop paired to a remote host, the CLI, and web silently ran on the legacy carrier forever and the canonical row was never exercised there. The renderer that paints it is the same build on every transport. * Update the web auth-frame expectation for the new capability --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
73d0521410 | Replace the sidebar create dropdown with two direct action buttons (#19653) | ||
|
|
0fe132ea29 |
fix(orchestration): file mail from terminals in no Run under an unbound Run (#19696)
* fix(orchestration): file mail from terminals in no Run under an unbound Run #19542 deleted the fallback that filed such mail under the legacy Run, because a live row there makes the schema-skew probe read the database as pre-Runs and replay adoption on the next open. That refusal also broke the first command in the guide: `orca orchestration send --to <handle>` between two plain terminals, which worked in v1.4.198. Restore delivery by filing under `run_unbound`, a Run the probe never matches, created on first use so `run list` shows it only to a user who has such mail. Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 * fix(orchestration): create the unbound Run only for a null Run id Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 |
||
|
|
750e6ffada |
test(orchestration): pin the Run-required contract for unbound direct mail (#19684)
* test(orchestration): pin the Run-required contract for unbound direct mail * test(orchestration): pin absent recovery keys and settle the push window for unbound mail --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
852495d35c |
fix(native-chat): unify launch routing and support structured worker placement (#19431)
* fix(orchestration): let worker-start actually produce a structured chat `orchestration.workerStart` reads the user's "open agent tabs in chat" default, but two placement checks downgraded a structured-preferring worker to a PTY terminal agent for the two flags a routine dispatch always passes: --worktree new-child / new-top-level -> worktree_creation --model / --effort -> launch_preferences so in practice a structured worker never happened. launch_preferences was stale. PR #19040 gave AgentSessionAttachParams `options` and added resolveStructuredLaunchSeedOptions, which narrows a saved selection to exactly `model` and `effort` — the two ids both structured providers accept as strings. --model/--effort now go through that same narrowing (extracted as narrowStructuredLaunchSeedOptions) and seed the worker's session instead of forcing a terminal. An option set that narrows to nothing resolves to undefined, never `{}`, which would fail the record's bounded-string guard under a code that is not a wire refusal and strand the launch with no fallback. worktree_creation was a consequence of createWorkerWorktree creating agent-first: its startup terminal WAS the worker, so the structured branch below it was unreachable for any new worktree. A structured worker now creates the worktree with no startup agent and creates its session for the worktree afterwards — the order the renderer's own structured worktree create already uses. Because the executing host can only answer agentSession.createSupport for a workspace that exists, that verdict moved after creation: a refusal (WSL, and the rest) becomes a terminal agent in the worktree just created, never a failed start. --on and --terminal still downgrade, with their reasons intact, and every remaining downgrade still states itself in the mode receipt. The wait-for-setup gate is preserved explicitly. A PTY worker got it for free — agent-first creation sequences the agent's startup command behind the setup runner, so tui-idle could not arrive until setup exited. A structured session has no startup command to sequence, so the gate is now awaited directly, bounded by the start's own timeout. Split out worker-worktree-creation.ts and worker-start-agent-placement.ts rather than growing two files that were both pinned at the max-lines cap. * refactor(native-chat): make shared feasibility authoritative for launch routing * Type the structured setup gate's absent blocked reason so the wait union stays property-typed The type-aware audit rejected the blocked-reason template literal: narrowing the wait union with an 'in' check left the field typed unknown. Declaring that a structured setup gate never carries a blocked reason restores the direct read. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
12f53da542 |
Remove settled-worker automatic resume and hibernation fences (#19544)
* Remove settled-worker automatic resume and hibernation fences * test: retirement rollback case follows the no-fence policy Case 4 seeded and asserted automaticResumeBlockedBy, which this branch deletes. A rolled-back settled worker is now an ordinary done record that wake clears as passive evidence, same as any finished agent pane. * chore(i18n): regenerate the runtime-required catalog for the contrast floor strings * test(orchestration): give the stopping-worker guard fixtures a Run |
||
|
|
2e19342c12 |
fix(terminal): remove host-retired ghost panes in paired remote splits (#19365)
Adds the missing removal path to the host-authoritative layout reconciler, so a pane the host has retired is unmounted once its PTY has cleared. Fixes #17770. The removal planner, its retired-set gate, the null-PTY guard, the never-last-pane guard and their unit tests originate from #18387 by @ylcn91. This PR adds the recovery-state dependency that makes the deferred removal actually re-run, an e2e regression spec, and a hook-parity repin. Co-authored-by: ylcn91 <7249450+ylcn91@users.noreply.github.com> |
||
|
|
ab32355701 |
test(e2e): fix automation and browser reconciliation tests (#19530)
- Update automations API to use runtime.call pattern with automation.create - Refactor browser creation flow to use state helpers instead of file explorer - Simplify Playwright selectors and context menu interactions - Remove fixture file creation from test setup |
||
|
|
f0bfc945b4 |
fix: avoid duplicate repository groups during catalog refresh (#19170)
* fix: keep grouped repositories visible after creation race * test: strengthen project group creation race verification --------- Co-authored-by: Kien Le <122910950+kien-ship-it@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
e182930670 |
test: cover input in five simultaneously flooding SSH panes (#19071)
* test: cover keyboard input in five simultaneously flooding SSH panes * test: capture pane focus and buffers on flood input failure * test: capture pane focus and buffers on flood input failure * test: capture pane focus and buffers on flood input failure * test: record replay input loss and application fix dependency * test: record merged replay-input fix in the five-pane flood gate |
||
|
|
66420537b7 | fix e2e create menu races (#19448) | ||
|
|
a278d84a4e |
fix(pi): show input modals as waiting instead of working (#18836)
* fix(pi): show input modals as waiting instead of working * test(pi): verify real input dialogs through Electron CDP --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
aeddfa463d |
perf(renderer): avoid per-second spinner animation events (#19407)
* perf(renderer): avoid per-second spinner animation events * fix(bench): ensure the Electron runtime before bench:spinners The script launches Electron via Playwright but skipped ensure:electron-runtime, which every other Electron-launching bench script runs first. * docs(renderer): scope spinner pixel-tolerance claim to paused-animation checks --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
c056c6f9ac |
Unify sidebar create actions into single dropdown menu (#19375)
* Unify sidebar create actions into a single dropdown menu - Combine "New workspace" and "Add project" under a unified "Create" button - Remove layout logic that split these actions based on sidebar width - Normalize "Add Project" to "Add project" (lowercase) throughout the UI * Use null instead of 'Unassigned' for unassigned shortcut labels Add formatOptionalPrimaryShortcutLabel that returns null when a shortcut is unassigned, enabling simpler conditional rendering in dropdown menus. Remove associated translation strings. |
||
|
|
ce4a3a4186 |
feat(chat): add structured session rewind backend (#19235)
* feat(chat): add structured session rewind backend * fix(chat): make interrupted session rewinds recover safely * fix(native-chat): negotiate rewind runtime capability * fix(native-chat): consolidate remaining adapter imports --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
6729f3a0b0 |
tools: add a phone-vantage relay connect benchmark (#19251)
* tools: add a phone-vantage relay connect benchmark Connect-speed work on the phone had no way to attribute latency to a hop. Timing the mobile app end to end only says "connect is slow", and a synthetic WebSocket probe does not exercise the credential check, the E2EE handshake, or the RPCs the phone blocks on before it publishes connected. This replays the shipped mobile wire sequence from Node against a real desktop over the production relay, so each phase gets its own number. The handshake is a plain-JS port of the mobile client session, which is only trustworthy if it stays byte-identical to what ships; a parity test runs it against the real desktop responder in the normal unit suite so drift in the transcript encoding, key schedule, or frame layout fails there rather than producing a bench that measures a handshake nobody uses. Adds a foreground mode for the resume-after-background question the phone lanes need: connect, go silent past the relay's client silence watchdog, then report whether the retained socket still answers and what the fallback redial costs. The bench writes a resume-credential bundle at runtime. That file carries a live device token for a real paired desktop, so the directory ignores it outright. * tools: make the relay bench name its target and opt in to dialing The supporting scripts carried production defaults: the director origin was hardcoded in both, and the hop-latency probe defaulted to a named production cell. Running either with no arguments sent live traffic at production, and the region probe did it on import, before any argument was read. A default like that is the wrong shape for a bench, because the operator never states what they are measuring against and a stray invocation is indistinguishable from an intended one. Every script now refuses to open a socket unless ORCA_RELAY_BENCH_LIVE=1 is set, and the director comes from --director or ORCA_RELAY_BENCH_DIRECTOR with no fallback. The cell origin is a required argument. Refusals print one line of usage and exit 2, so an accidental run is inert rather than live. The remaining host-id default is an id no desktop owns, which is the point of that probe: it measures the cell hop without reaching a desktop at all. * tools(relay-bench): type refuse() as never so origins are strings * tools(relay-bench): fail closed on hostile input and bounded arguments Review found the harness trusted whatever it was handed: the DevTools port and the director-supplied probe origins went straight into a URL, http origins were accepted, repeat counts came from a bare Number() cast, and the state file kept its existing mode. - Validate the DevTools port as a 1-65535 integer, so '80@attacker.example' cannot move the fetch off loopback via URL userinfo. - Require https for every origin, and refuse loopback, link-local, private, and multicast destinations. Region probe origins and the cell URL the director returns go through the same check, so a compromised director cannot aim the harness at the operator's own network. - Bound --runs, --rounds, runs, --gap, and --hold as whole numbers, so 'Infinity' exits 2 instead of looping forever against the relay. - Report a region as UNREACHABLE when every probe fails, rather than letting Math.min([]) spread into NaN and read as ok. - Bound the director /v1/resolve and /v1/regions fetches and report timeouts. - Return null openMs when the socket never opened, and clear dial, cell, and RPC timers on the first terminal event so Node exits promptly. - Default handle.rpc() to RPC_TIMEOUT_MS, not DIAL_TIMEOUT_MS. - Write the state file through a helper that creates the parent directory, refuses a symlink, and forces 0600 on an existing file; refuse to read one that is readable beyond the operator. - Read the pairing link from stdin or a 0600 file, never argv. - Reject missing and invalid positionals with usage and exit 2. Adds unit tests for the pure guards: argument parsing, bounded integers, port and origin classification, DNS vetting, state-file modes and symlinks, region verdicts, and pairing-link decoding. None opens a socket, and every network path stays gated on ORCA_RELAY_BENCH_LIVE=1. * tools(relay-bench): settle in-flight rpcs and guard an empty region catalog Follow-up to the review fixes. Clearing a pending rpc timer without a resolution swapped a 15 s timeout for an await that never returns, so the teardown paths now settle each waiter with a closed result. A director that answers /v1/regions with no regions now reports that and exits 1 instead of printing an empty round. * tools(relay-bench): attach the origin-vetting doc to the function it describes * fix(tools): resolve a director-named cell through DNS and fail cdp-eval clearly |
||
|
|
374c676f6d | fix: repaint hidden output overflow after answered restore deadline (#18904) | ||
|
|
ba4e79c250 |
fix(runtime): apply the structured-chat setting to every RPC caller (#18700)
* fix(runtime): apply the structured-chat setting to every RPC caller
supportsStructuredAgentSessions only consulted experimentalStructuredNativeChat
when clientKind === 'mobile', so identical host settings admitted desktop and
in-process callers while refusing a phone. The server branched on client surface.
The setting is now one rule for every caller. The negotiated capability stays a
wire term asked of remote clients only, so a capability-less in-process caller is
still admitted on the setting alone.
Making the projection's structuredNativeChatEnabled argument required surfaced
eight call sites that passed `undefined` for non-mobile clients; they now read the
host setting, so tab projection follows the same single rule.
Announced behaviour change: with the flag off, session.tabs.list/listAll no longer
restore structured tabs for desktop. The desktop renderer already discards them in
that state, and startup record/lease reconciliation is unaffected.
* fix(runtime): keep structured session cleanup available
* test(runtime): enable structured chat in desktop projection fixture
* test(agent-session): settle merged fixtures against the all-clients structured policy
The merge with main left three fixtures written for the old mobile-only rule:
a duplicate getClientSettings key, a create fixture with no host settings at
all, and a projection call whose 'old client' is now the mobile fallback-title
case.
* fix(native-chat): let an admitted caller close a chat after the setting is off
Turning `experimentalStructuredNativeChat` off revoked admission for every
`agentSession.*` method, including `close`. A chat opened while the setting was
on stays mounted, so its owner was left with a live provider child and an X
button that answered `structured_agent_session_unsupported`.
Split the surface by what a method does to work in flight rather than by how it
sounds, and write that rule where the gate lives so the next method lands on the
right side: starting, extending, retaining or reading needs admission; stopping
or retiring work the caller already owns does not. Moves `close` and `cancel`
onto the cleanup gate alongside `unsubscribe` and `release`.
The tightening is unchanged - the cleanup gate still demands the negotiated wire
capability and never creates a host, so an incapable client still cannot see the
surface and no method that starts work is reachable with the setting off.
Extracts the dispatcher harness and the method-to-gate table into fixtures so
the new admission suite can share them without a max-lines disable.
* Drop a duplicate lastActivityAt key carried in from main
The main commit this branch merged (
|
||
|
|
f1d8545024 |
feat(chat): support structured /clear and /compact commands (#19164)
* feat(chat): support structured clear and compact commands * fix(chat): authorize mobile commands and bound clear-chain projection * fix(chat): localize conversation command send errors * fix(chat): retain clear pane identity with reopened history * test: account for combined structured session RPC additions --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
bf4e270504 |
fix(native-chat): list the slash commands and skills a structured Claude session actually loaded (#19127)
* fix(native-chat): list the slash commands and skills a structured Claude session actually loaded The chat composer's `/` menu was built from a curated five-command catalog plus a host disk scan of skill roots. Neither is what the running session can do: the session reports its own `/` surface, which carries this repo's `.claude/commands`, the skills that only reach it through plugin roots, and a hide-list of commands that mean nothing outside a terminal UI. On one local session the menu offered 6 commands and 17 skills where the session reported 62 commands and 33 skills. Read that surface per session and let it drive the picker: - A per-session catalog seeded from the frame that proves the session and kept current by every later report, exposed over a new `agentSession.commands` read. - The report is the authority on WHICH skills exist; the disk scan stays the source of scope and description for the names both know about, so a skill the session never loaded is no longer offered and one it loaded from a root the scan cannot see now is. - A host that predates the read answers `method_not_found` and the composer keeps its curated catalog, so mixed versions and the PTY lane are unchanged. * test: register agentSession.commands on the three surface ratchets The structured method count, the mobile allowlist, and the cross-version call table each enumerate the agentSession surface on purpose, so an additive method has to be declared in all three rather than counted around. * fix: preserve session catalog authority and publish live updates * fix(native-chat): publish authoritative command catalogs on session updates * fix: seed Claude slash catalog before the first prompt * test: verify unclassified catalogs survive session publication * test: complete structured rename journal fixtures --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
68dd3909c7 |
feat(orchestration): orchestrate native-born structured chat sessions (#18827)
* feat(orchestration): orchestrate native-born structured chat sessions Orchestration resolves every worker through a terminal handle and a pane key backed by a live PTY. A session created directly as structured has neither, so it was not refused by orchestration — it was invisible. A coordinator could not start one, address one, or receive `worker_done` from one. Add a second authority source rather than a parameter channel. A registry maps a session id to the same three facts the PTY path supplies — a bearer handle, a pane key and a host scope — and the four runtime getters consult it before giving up on `ptysById`. `orchestration.send` and `verifyDispatchCapability` are untouched: authority stays host-derived and the CLI still cannot assert who it is. PTY handles short-circuit on the handle prefix, so the terminal path is unchanged. Mail travels as a session turn instead of as bytes, on a sibling lane that keeps the PTY lane's outstanding-run, waiter, reserved-type and batch rules. Orchestration's database stays the source of truth; the send is best-effort, exactly as the byte write is, and mail is consumed only on a proven-accepted dispatch. Delivery waits for the session to be between turns, because one provider refuses a mid-turn start outright and the other cannot acknowledge one inside the ack window. Security properties, each pinned by test: the pane key's leaf is random and persisted rather than derived, since `check` is identity-gated and accepts a caller-supplied pane key; the handle is a random bearer token; the child env carries no pane key, which would otherwise flow into hook pipelines that assume a PTY leaf; hook attestation stays closed for structured handles; and process continuity comes from record lineage, never the runtime fence, which the host bumps during its own crash recovery. Also remove the "Orchestration paused" notice, which gated only on dispatch status and rendered over bridge chat where orchestration always worked; refuse the implicit-sender fallback when a worktree has more than one candidate leaf instead of guessing; and collapse the archive kinds to one named type with a compile-time assertion that the capture set cannot drift ahead of the storable set. * fix(orchestration): answer the structured idle gate from the reduced timeline The structured pointer gate read a bounded 40-item tail page. A settled turn is tombstoned rather than rewritten, so an idle worker with any real history carries no turnLifecycle item at all and the "full page, no lifecycle item" guard read it as busy forever: every nudge after the worker's first substantial turn parked on a settle edge that had already passed, and the preamble tells workers not to poll. The attention gate had the mirror bug — a prompt older than the tail window was missed and the nudge was delivered into a session blocked on a human. Both facts now come from `journal.snapshot()`, the fully reduced timeline, via a new narrow `readGateFacts` host read; the policy module stays pure and still projects through the shared helpers the chat view reads. Also: - Park `session-not-attached` on the journal edge, so mail that arrives during a transient detach is redriven by the re-attach reset instead of sitting unread. - Resolve a structured worker's provider from the durable agent-session record when the registry entry was rehydrated, so a restarted Codex worker is no longer reported and archived as Claude. - Clear `structured_pointer_operations` in every `orchestration reset` scope. - Drop the per-chat-pane dispatch-status store subscription left behind by the removed paused notice, and re-pin the two terminal-pane ratchets it moves. - Hoist the identical pointer batch selection out of both delivery lanes into `selectOrchestrationPointerBatch`. - Refuse the pre-graph-ready focus-based guess for `requireUnambiguous` callers, matching the ready path. - Move the host teardown phase list into the teardown module it belongs to, which is what keeps the host inside its max-lines budget. * fix(orchestration): discard a structured worker session whose create settled unknown `commitStructuredAgentSessionCreate` answers `agent_session_operation_unknown` when `attach` SUCCEEDED and only the tab publish failed, so `created.ok === false` is not proof that nothing exists. The worker start read it that way and skipped `discardCreatedSession`, leaving a live provider child that took no hold, has no `bindingsByDispatchId` entry and no published tab — the outer `releaseStructuredWorkerSession` no-ops without a binding, and a session that never had a holder never starts the eviction clock, so nothing in the runtime ever retires it. A throw out of the commit half is past `attach` for the same reason; the pre-commit half refuses rather than throwing. Cleanup now asks whether the create MAY have committed, via the existing `isDefinitiveAgentSessionCreateRefusal` predicate. Also: - Strengthen the pre-ready `requireUnambiguous` test so it actually pins the guard: the snapshot now carries a focused terminal, so deleting the `? [] :` ternary turns the test red instead of leaving the refusal to the ambiguous `listTerminals` fallback. - Correct the guard's justification comment, which cited `orchestration check` as covered. `check` resolves through the `--terminal` scope and still guesses; the guard covers the implicit `--from` sender, and a structured worker is covered by the `ORCA_TERMINAL_HANDLE` baked into its child. * docs(orchestration): stop two structured-worker comments claiming guarantees the code does not give The send-time owner re-check reads `target.refusal`, the snapshot the resolver already admitted, so `decideStructuredPointerDelivery` can only agree with the resolve-time answer and `owner-not-settled-native` is unreachable from that call site. What actually fences an owner that moved is `expectedRuntimeFence`, which a handoff bumps. Say that, so nobody later drops the fence trusting a re-check that is structurally a tautology. `discardCreatedSession` was credited with retiring "a published background tab that no dispatch owns". It hides the DURABLE tab reference and closes the session; the live tab snapshot keeps the row, so the background tab this start published stays on screen until the app restarts. Same for stop and release. The comment now describes what the two calls do — including that both are no-ops on a session that was never attached, which is what makes the non-definitive-refusal path safe to reach unconditionally. * fix(orchestration): retire a structured worker's chat tab when the worker settles Starting a structured worker always publishes a real `agent-session:<id>` tab, but every settlement path only called `setSessionTabVisibility(sessionId, false)` plus `host.close(sessionId)`. That clears the DURABLE restore index and leaves the LIVE snapshot untouched, so stop, release and the half-started discard all left a dead "Claude Chat" / "Codex Chat" tab in the worktree's tab bar for the rest of the app session — five dispatches, five dead tabs — and opening one re-attached the released session, respawning a provider child outside orchestration's hold accounting. The snapshot-pruning half of `closeStructuredAgentSessionTab` is extracted into `structured-agent-session-tab-retirement.ts` and exposed on the runtime as `retireStructuredAgentSessionTabFromSnapshot`, so the user-initiated tab close and the three settlements share one implementation instead of a second copy. The settlement side is best-effort BY CONSTRUCTION: it runs only after the close is already proven, calls the runtime method optionally, and swallows any throw. It talks to no renderer, so the startup release reconciler can call it too. Nothing here can turn a proven stop into `release_unknown`. * fix(orchestration): stop a structured worker's nudges, archive and liveness from lying Five defects in the structured-worker lanes, each with the same shape: a check that answered from something other than what it claimed to measure. - The pointer lane gated a WORKER's `dispatch:` mailbox on its RUN's outstanding delivery. Delivery rows exist only for a `run:` address, so that row belongs to the coordinator — and a coordinator holds one for exactly as long as it is acting on received mail, which is when it replies to its workers. The gate is gone; there is no coordinator mailbox in this lane to protect. - `dispatch-rejected` now parks on the journal edge. A rejection consumes no mail and nothing else redrives the mailbox, so an unparked pointer left the worker idle on durable mail until unrelated mail happened to arrive. - The released journal archive bounded forward — keeping the HEAD — before capping newest-first, so a long worker's archive ended at its early exploration and dropped the answer it was released for, under a warning that said the oldest messages had gone. One newest-first pass now, and the warning is true. - The durable pointer operation id was reused on a matching BODY fingerprint, and the body names only the unread count. Two unrelated same-size batches collided, the host replayed its ledger answer as `accepted` with no turn sent, and the lane marked the new mail delivered. Reuse is keyed on the batch's message ids. - `worker-read` on a structured worker hardcoded `terminal: 'running'` and emitted no `liveness`, so a runtime that could not see the session reported the worker as alive. It now carries the observed verdict, as the PTY branch does. Also: the live journal cursor is an index into a re-derived tail window, so the page's oldest item joins its source identity — a slid window now answers `source_changed` instead of silently resuming past the items it skipped. And a stop that reached no host reports `processAction: 'none'`, after installing the host the way release already does. * fix(orchestration): stop a released structured archive claiming a close that never landed `worker-read` on a released structured worker hardcoded `liveness: 'exited'`. The archive is frozen BEFORE the close, so it proves nothing about the provider child, and the read is served for `release_state` in `releasing` / `unknown` too — the two states that exist precisely to record a close that did NOT land. A coordinator that read `exited` from a `release_unknown` worker would start a replacement over the same worktree while the original child was still attached, which is the outcome docs/reference/ssh-execution-boundary.md rule 2 exists to prevent, and it contradicts the release receipt's own "the structured session close was not proven" text. The verdict now comes from the resource row the read already holds: only a settled `released` row is `exited`, everything else is `unverifiable` — which the existing mapping renders as `terminal: 'unknown'`, the same way the live branch does. * fix(orchestration): stop a structured worker-start reporting a preamble it never delivered Two ways a structured `worker-start` handed the coordinator a receipt that did not describe the worker it got. `sendStructuredWorkerPreamble` threw only on a refusal and on `rejected`, so a submission that settled `unknown` fell through as success: the start pushed `dispatch_input: accepted` and marked the dispatch ready. `unknown` is not rare — `dispatchSafely` converts ANY thrown adapter call (provider child gone, transport dropped, ack window missed) into it, and `performSend` still returns ok. The worker then has no task spec while its coordinator blocks in `check --wait --types worker_done` until timeout. This PR's own mail lane already states the rule — "`pending` is not yet an acknowledgement; only `accepted` may consume mail" — so the preamble now applies it too, and raises `operation_unknown` for the states that prove neither delivery nor failure, which is the code `failWorkerStartWithReceipt` turns into the `outcome_unknown` receipt whose nextCommands send the coordinator to look. `rejected` stays a proven failure. `--structured` also accepted `--model` / `--effort` and dropped them: structured session creation takes no launch preferences, while `launch.receipt.effective` echoes whatever was requested either way, so `--model opus` ran on the workspace default and the receipt still said `opus`. Refused now, for the same reason `--terminal` refuses them, and the spec note records that refusal along with the new-child/new-top-level one it never mentioned. Tests: the refusal guard had no coverage at all, and `structured-mailbox-pointer-host` — where the full-timeline gate read lives — had none either; reinstating the bounded tail there left the whole repo green. Both are covered now, and the vacuous "never selects an exact provider session" case is re-pointed at the absent `ORCA_PANE_KEY` that actually keeps that selector shut. * fix(orchestration): let a structured worker actually reach the Orca CLI, and stop four settlements lying A structured worker's provider child runs `orca orchestration ...` exactly like a PTY worker's agent does, but it was handed the ambient PATH. On packaged Linux the CLI installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (#7904), so bare `orca` execs the screen reader and the worker can never read mail, reply or send worker_done; on packaged macOS/Windows the bundled launcher is only reachable from the app's own resources dir. The PTY lane already solves this inside `buildPtyHostEnv`; that block is now its own module and both lanes call it. Also: - a worker start that fails AFTER its session exists now discards the session, so a failed start stops stranding a dead chat tab that the durable restore index republishes on every launch; - a structured worker's resource reconciles to `released` after settlement forgot its identity, instead of answering `unverifiable` for the life of the DB; - `closeAttempted` is set only once a close is issued, so a tab-visibility failure can no longer report `closed_agent_terminal` for a running child; - `forgetSession` prunes only what the settled worker parked, not every sibling whose target momentarily fails to resolve; - release settles with an explicitly empty, warned archive when the journal is unreadable AND the session is proven exited — closing the chat tab is routine, and `archive_failed` there wedged release on evidence that could never arrive; - the new migration test uses mkdtemp and cleans up, so it stops failing Windows CI and leaking. * fix(orchestration): merge the duplicated release-receipts import The release-completion module imported ./orchestration-worker-release-receipts twice, which trips import/no-duplicates in audit:code-quality:native. The changed-file gate does not load that config, so only whole-tree CI saw it. * docs(runtime): note that a background structured tab re-publish is a no-op The activate:false branch for an already-published session returns without writing the snapshot or emitting, so it cannot re-surface a client whose mirror lost the tab. Orchestration is safe from this only incidentally. * feat(orchestration): make the worker mode the user's own default, not a flag `worker-start --structured` was an explicit opt-in that REFUSED --on, --terminal, --model/--effort and worktree-creating placements. The flag, its spec entry and the `structured` RPC param are gone: the mode now follows the user's setting for new agent tabs, so a local claude/codex worker is a structured chat session whenever the user's own default says agent tabs open as one. A setting is a preference, not a demand, so none of those combinations refuses any more. A dispatch that cannot be structured starts an ordinary PTY terminal worker and the receipt names the mode that ran and why, so the fallback is never silent: - a remote --on, an existing --terminal, a new-child/new-top-level worktree and --model/--effort are decided from the request; - the agent, TUI launch customization, Codex-on-Windows and the runtime capability are decided by the shared launch route; - WSL, remoteness and the Windows start-time gate are settled by the executing host's own agentSession.createSupport, asked once the worktree resolves and before anything is created, so a refusal is a terminal worker rather than a failed start. The decision is the renderer's, lifted rather than copied: `resolveAgentLaunchRoute`'s structured half and the settings predicate now live in shared/structured-native-chat-launch-route, which both surfaces call, and the TUI launch customization test moves to shared beside it. `getClientSettings` gains the two native-chat default booleans it was missing. No security invariant moves: the structured worker registry, bearer handle, persisted pane key, the absence of ORCA_PANE_KEY from the child env, hook attestation and lineage-derived process incarnation are untouched. * fix(orchestration): stop the worker mode leaking into the agent contract The mode a worker runs in is a runtime implementation detail. An agent should be taught the same verbs, run the same commands and read the same receipts whether it is a structured chat session or a PTY terminal — otherwise a settings-driven fallback silently changes what the agent can do. The real leak was `canDispatchSubWorkers`, which was forced false for a structured worker. That was not a wording choice: `worker-start` resolved `--from` through `showTerminal`, which needs a live PTY or renderer leaf, so a `structworker_` coordinator genuinely could not dispatch. Rather than withhold the capability, the one fact the command needs from `--from` — its worktree id — now comes from `getOrchestrationDispatchAuthority`, the same authority the pane-key and process-incarnation getters already answer structured handles from. Sub-dispatch is gated on depth alone, identically for both modes. `showTerminal` itself is deliberately NOT taught structured handles: it returns a ptyId, a leaf id and a pane runtime id, and synthesising those for a session with no PTY would hand every caller of a public terminal verb something that looks writable and is not. `inspectWorkerTerminal` already returns `terminal: null` for exactly that reason. Also neutralised three agent-visible refusals that named the worker's kind: a `worker-read --source terminal` on a worker with no terminal now names the sources that do work, and both archive refusals say "transcript output" rather than "structured chat output" (the PTY `transcript_pin` branch said "structured" too). New tests pin both properties: the two preambles are byte-identical once the handle and per-dispatch ids are normalised, and a structured coordinator starts a worker with `showTerminal` rejecting. * fix(orchestration): stop claiming a structured worker was checked for a prompt worker-show reported observation.agentWait: null for every structured worker. The field's own contract says null means Orca looked and found no wait, and absent means it never looked — and nothing looks here: a structured worker parks on a journal question item, which no terminal prompt scan can see. So null was a false negative on the one field a coordinator is explicitly told to read, and it was mode-dependent: the same worker as a PTY would have reported the wait. Absent is both the honest value and a state a PTY worker already reaches (an older host, an unreadable pane, a probe that did not answer), so it discloses nothing about which mode ran. * docs(cli): stop the worker-start spec pointing a caller at the worker kind The note said "the receipt mode field names the mode used and why", which is an instruction to read a field no verb behaves differently for — the one thing the mode was not supposed to become. It now says what a caller actually needs: the dispatch always starts, the options passed are the ones honoured, and every worker is driven the same way. The receipt still carries the mode for operators and telemetry; nothing tells an agent to look at it. * perf(orchestration): coalesce the structured redrive edge Every journal batch is a redrive candidate, because a settled turn is tombstoned rather than rewritten — there is no completed row to watch for. That is free while nothing is parked on the session, but once mail IS parked each batch re-resolved the dispatch, queried unread mail and read the host's gate facts, only to re-park because the turn was still running. A turn streaming tool calls paid that per batch. The edge now coalesces on a 300ms quiet window with a 2s starvation cap, so a streaming turn costs a handful of evaluations instead of one per batch and a settled turn still nudges promptly. Delivery semantics are untouched: the gate, the accepted/rejected/unknown handling and the retain rules all still run exactly as before, just fewer times. Nor is this the path fresh mail takes to an idle worker — that is `deliverForHandle` at enqueue time, which this does not touch — so the common case gains no latency. The mechanism is the session.tabs notify coalescer, generalised into `keyed-trailing-edge-coalescer` and called by both rather than duplicated; the session.tabs windows stay where they were, since 50ms is right for a spinner title and far too tight for a journal stream. Disposal drops the pending timer rather than flushing it, on the existing subscription disposer that every settlement already reaches, so a redrive can never fire for a session no dispatch owns. * fix(orchestration): deliver direct peer mail to a structured worker, and let a peer read it Two agent-to-agent verbs had no answer for a worker that IS a structured agent session, and both failed quietly. Mail addressed to a worker's own bearer handle — how agents mail each other outside a dispatch — fell between the lanes. The send stored durably and reported success, `getLiveTerminalPaneKey` resolved the recipient, and then neither lane claimed the mailbox: the structured resolver answered only `dispatch:` addresses, and the PTY lane refuses a structured handle outright. Nothing errored and nothing logged, so the worker never reacted and the peer waiting on a reply hung. The resolver now also answers a bare worker handle, preferring that worker's active dispatch so peer and coordinator nudges share one operation-ledger budget. A worker BETWEEN dispatches is still nudged, under a session-scoped key: a dispatch says nothing about whether delivery is safe — the idle gate and the lease fence do — and its own `check` reads exactly the direct mailbox the mail is sitting in. The dispatch caller key is left byte-identical, because the ledger is keyed on (callerKey, operationId) and reshaping it would re-mint nudges already in flight as second turns. `terminal read` had no structured branch, so the only peer-accessible read verb answered `terminal_handle_stale` for a live worker; `worker-read` is closed to a peer, which holds neither coordinator standing nor a dispatch id. It now serves the session's journal, projected to LINES and paged by the same reader the PTY tail uses, so the result stays a plain RuntimeTerminalRead and nothing an agent reads discloses which kind of worker answered. Bounding and dispatch-capability redaction are the archive path's, reused rather than rebuilt. A session that is not attached refuses with the existing not-attached code rather than returning an empty tail, which would read as "this worker has said nothing". `terminal.show` still refuses a structured handle. This is read-only on purpose: synthesising a ptyId/leafId/paneRuntimeId would hand every public terminal verb something that looks writable and is not. * fix(orchestration): stop three PTY-only probes answering for structured sessions Three defects, one shape: a probe that enumerates PTYs or resolves a pane was standing in for a question that is not about panes at all. `worktree rm` destroyed a live structured worker. `killAllProcessesForWorktree` sweeps the renderer graph, the provider session list and the local pty-registry, and a structured session is registered on none of them — so all three counted zero, nothing errored, and removal deleted the checkout out from under a running provider child, which kept running with its `cwd` gone while the dispatch still reported the worker live and exact. A fourth sweep now asks what the other three cannot: membership by `location.workspaceId`, which covers a plain chat session as well as a dispatched worker, and liveness by the same `live`/`unverifiable`/`exited` observation the rest of the structured surface uses. It REFUSES a destructive removal rather than auto-closing, on the same bargain and the same `--force` escape hatch as the unstopped-PTY gate — this is the verb that deletes a user's work, and a running agent is exactly what they would want to be told about. Force closes the sessions properly instead of orphaning a child. Best-effort reconciliation callers are excluded: they repair state, delete nothing, and must never be failed closed. Twelve coordinator verbs failed for a structured worker running as itself. `isLiveTerminalHandle` validated `ORCA_TERMINAL_HANDLE` with `terminal.show`, a PTY verb whose leaf lookup misses for a session that never had a pane; the pane remint that would have recovered it needs `ORCA_PANE_KEY`, which a structured child deliberately does not carry, so every one of them died on `no_active_sender_terminal` — including the ones the worker's own dispatch preamble tells it to run. The identity question gets its own probe, `terminal.resolveIdentity`: a handle and a boolean and nothing writable. `terminal.show` still refuses a structured handle, because synthesising ptyId/leafId/paneRuntimeId would hand every public terminal verb something that looks writable and is not. The PTY half is byte-for-byte today's check, `getLiveLeafForHandle` included, so its `rendererGraphEpoch` re-check still runs — that check is the whole reason the sender is validated at all, and a cheaper probe would have quietly started passing stale post-reload handles. A host that predates the method answers `method_not_found` and the client falls back to `terminal.show`, which is correct for that host: one without the identity probe has no structured workers to miss. `dispatch --inject` reported `no_agent_detected` for a structured worker, because `isTerminalRunningAgent` reaches `getLiveLeaf`, throws, and the catch returns false. A structured session IS the agent; there is no foreground process to recognise, so it answers before the PTY probes rather than through them. Also: a Run whose coordinator is structured now gets its `run:` mail. Both lanes declined and neither logged — the PTY lane because the owner is structured, the structured lane because the mailbox was not `dispatch:` — so each half believed the other owned it. The PTY lane's reasoning (a coordinator blocks in `check --wait`, where a waiter preempts pointer delivery) does not transfer: a structured coordinator is a chat session whose turn ends. Its `run:` deliveries take the `hasOutstandingRunDelivery` gate the PTY lane applies for exactly that mailbox, and only for that mailbox. The test that would have caught the twelve drives the CLI with `ORCA_TERMINAL_HANDLE=structworker_…` and no `--from`. Every existing orchestration CLI test passes `--from` explicitly, so the resolver a real worker goes through was never exercised — which is why the suite stayed green while the preamble failed on its first line. Two files crossed their line ceiling and are split rather than waived: `worktree-teardown.ts` sheds its two PTY-surface sweeps and the deadline arithmetic they share, and `orchestration.test.ts` — which sat exactly on 800 — sheds the two caller-identity suites this change rewrote. * fix(orchestration): arm the takeover signal for structured chat input `worker-release` closed a structured session a user had taken over, losing work mid-conversation, while `orchestration-worker-specs.ts:106` promised "Never closes … user-taken-over terminals". Every guard was already correct and simply never armed. `reportWorkerTerminalUserInput` has exactly one call site — the real-user-input signal on a PTY connection — so structured chat input never reached `orchestration.workerTerminalUserInput`, `markWorkerTerminalUserOwned` never ran, ownership stayed `owned` instead of `user_owned`, `retainedReason` never returned `user_takeover`, and `stopStructuredWorker` proceeded. The durable flag is reused as-is rather than given a parallel mechanism: it exists precisely so a restart, an SSH drop or a renderer remount cannot erase a takeover. Addressed by SESSION, never by pane key. A structured worker's pane key is a random identity credential — anyone holding it can read and consume that worker's mailbox, and session ids are embedded in tab ids in plain text — so it stays in main and the runtime resolves the session to it. Handing it to a renderer to echo back would make it learnable by anyone who can see a chat pane. The RPC gains an optional `sessionId` alongside `paneKey`; a host that predates it rejects the call, and the report is already best-effort with a catch, so that host degrades to exactly today's behaviour rather than failing a send. The signal fires from the composer send hook and only past `accepted`: the outbox dispatcher retries, and orchestration's own pointer nudges never pass through the composer at all — so neither can be mistaken for a user takeover. * fix(orchestration): reach structured workers through group addresses `orca orchestration send --to @all` — and `@idle`, `@claude`, `@codex`, `@worktree:<id>` — silently skipped every structured worker. Recipients came from `listTerminals`, which enumerates leaves and PTYs, and a structured session is on neither. The exclusion happened BEFORE per-recipient resolution, so the `SendRecipientWarning` machinery never ran: the caller got exit 0 and a receipt naming the workers that did resolve, and a broadcast "stop work" or "base moved" reached the PTY workers and nobody else. With every worker structured it degraded to `terminal_not_found`, which reads as "the group was empty". Fixed at the group-resolution site rather than inside `listTerminals`. That result is published to paired mobile and remote clients and to consumers that assume a summary carries a `ptyId` or is writable, so widening it is its own change under `docs/reference/remote-wire-compatibility.md`. Group addressing reads exactly three fields off a recipient, and `RuntimeTerminalSummary` already satisfies them structurally, so the resolver widens to that smaller shape and nothing here invents a `worktreePath` or a `branch`. Candidates are liveness- gated on the same observation the rest of the structured surface uses — mail addressed to a settled worker would be stored for a lane that will never deliver it — and once a worker IS a candidate, the existing per-recipient warnings cover it, so an unresolvable one is reported rather than dropped. `@idle` needed more than enumeration: `getAgentStatusForHandle` reaches a PTY probe that throws for a handle with no pane, so a structured worker would have been enumerated and then silently dropped from the one group address that selects on status. It now answers from the session's journal — and off the FULL reduced timeline, never a bounded tail. Settlement tombstones the running turn's lifecycle item rather than rewriting it, so on any page-sized read a long tool-calling turn looks identical to an idle session; `@idle` would then broadcast into a running turn, which Codex answers with `turn already running` and Claude queues behind. An unreadable session answers null, never idle. `terminal list` and `worktree ps` still omit structured workers; that is the wire-visible half and is deliberately not in this change. * fix(orchestration): refuse rather than guess when a chat session has no identity An ordinary structured chat session — not a dispatched worker — is spawned with no `ORCA_TERMINAL_HANDLE`, because `structuredWorkerChildIdentityEnv` early- returns for any session outside the worker registry. `orca orchestration check` then fell through to `terminal.resolveActive`, which picks the focused tab's active leaf or the first leaf in the worktree. It returned a valid handle, so nothing errored — and `check` is destructive by default, so it consumed another pane's oldest unacknowledged batch and marked it read. The rightful worker never saw that mail. `requireUnambiguous` does not fix this, only narrows it: it refuses when MULTIPLE leaves could be meant, and with exactly one terminal pane in the worktree the guess still resolves — to a sibling. "One terminal pane plus one chat tab" is a normal layout, so the common case stayed broken. The pinned test is that case. So the child now carries `ORCA_STRUCTURED_SESSION`, and every remaining route that would GUESS an implicit terminal refuses on it with an error naming the flag to pass. The marker names NOTHING — no handle, no pane key, no session id, no token — which is the whole reason it is safe: it cannot be replayed, cannot impersonate, and cannot flow into the hook-attestation, agent-row or mobile-projection pipelines the way a pane key would. That makes it a different decision from withholding `ORCA_PANE_KEY`, not a reversal of it. It also grants no CLI reachability, so packaged builds keep exactly today's exposure. The comment at `orca-runtime-adopt-terminal-orphans-from-inventory.ts` that justified the guess — "a structured worker is covered instead by the `ORCA_TERMINAL_HANDLE` its child is spawned with" — was true only for dispatched workers and false for every other structured session, a population this branch creates. It now says which case it covers and which case it does not. * fix(orchestration): stop two surfaces lying about a worker with no terminal `orca terminal <verb>` answered `terminal_handle_stale` for a structured worker's handle. Nothing went stale: the session is live and simply has no terminal, and it never had one — so callers acted on a false claim and went hunting for a remint that cannot exist. The refusal now carries its own code and names the structured equivalents (`orca terminal read`, `worker-read --source transcript`, `orca orchestration send`), so an agent that lands there learns what to run rather than what failed. A PTY handle that really did go stale keeps the old error, and so does a session this runtime no longer owns — that handle IS dead. `terminal.show` stays non-resolving: synthesising a ptyId/leafId/paneRuntimeId would hand every public terminal verb something that looks writable and is not. `orchestration-worker-specs.ts` promised "the same verbs, the same handle, and the same worker-read sources", and all three clauses were false for a worker with no terminal. A spec agents read must not carry a false promise, so it now states the limitation and the alternative that always works. Note this had to be reconciled with an invariant this branch already holds: the worker MODE must stay opaque, or a coordinator starts branching on something no verb it runs behaves differently for. So the note says "not every worker has a terminal" and points at `--source auto`/`--source transcript` WITHOUT naming a kind — the same mode-neutral wording `readStructuredWorkerOutput` already uses when it refuses `--source terminal`. Both properties are now pinned by tests, so neither can be restored by breaking the other. * fix(orchestration): close the review findings on the structured parity work Four defects and two follow-ups from the delta review. The `worktree rm` refusal was a dead end in the desktop UI. Its message matched no matcher in `classifyWorktreeForceDeleteReason`, and an ordinary desktop delete already passes `force=true` for the dirty-file skip, so classification returned null unconditionally: the toast showed raw CLI wording with no Force Delete button, and a user with a live chat session was stuck unless they knew to reach for the CLI. That is the #11960 shape `shared/worktree/removal.ts` documents, so the refusal now has its own prefix, matcher, `WorktreeForceDeleteReason` and toast copy, classified BEFORE the `force` guard and nulled once the waiver is spent — exactly how `unstopped-pty` is handled, with matcher and hint kept in the same file as that contract requires. The copy says Force Delete will close a running conversation rather than borrowing the "could not confirm" wording, because Orca watched these sessions stay attached; there is no doubt to waive. Structured `terminal read` cursors were unsound and are now refused. The PTY cursor indexes an append-only completed-line buffer with a monotone count; a session journal is a BOUNDED tail re-projected on every read, so a saved index addressed different lines as the journal grew — and `truncated` could never fire to say so, because it tests `cursor < oldestCursor` and `oldestCursor` was always 0. A poller got wrong or duplicated lines under `truncated:false`. Separately, a streaming turn's lines counted as completed with `partialLine` hardcoded empty, so a mid-turn cursor consumed a half-written line whose growth was never redelivered — the `"hel"`/`"hello"` hazard the PTY reader guards against. The journal does have stable item identity, but `terminal.read`'s cursor is a number on the wire and cannot carry it, so a cursor read now refuses and names `worker-read --source transcript`, which already has that contract including `source_changed`. No cursor space is advertised either: `nextCursor` is null and the cursor fields are absent, rather than claiming an index the next read cannot honour. The header claim that all four fields kept their meanings was true of the shape and false of the invariants; it now says which ones hold. Two fixes had no test at their real seam, which is the same failure that produced this whole set — the runtime tested directly, the seam tested by neither. The group-addressing test hand-composed the recipient list itself, so deleting the composition at the call site left it green; it now drives `sendGroupMessage` with no PTY terminals at all. Nothing referenced `isLiveStructuredAgent`, so the `dispatch --inject` fix had no red-then-green at all; it now has one driving `RuntimeTerminalAgentPresence.isRunning`. Both were ablated and confirmed red. Folder-workspace removals sweep and kill PTYs without `requirePhysicalStop`, so the structured sweep no-opped there and left a live session bound to a workspace about to be forgotten. They now close best-effort under an explicit `closeStructuredSessions` flag, kept separate from `requirePhysicalStop` because the two questions differ: that one asks whether a stop must be PROVEN before files are touched, and it is what licenses a refusal. These paths do not refuse — the root is shared so no checkout vanishes under the child, and one of them is a never-throw forget a refusal would wedge. Reconciliation sweeps set neither and still close nothing. Also: the force close is raced against the same sweep deadline every PTY surface is bounded by, so a wedged provider close reports the timeout instead of hanging `worktree rm --force` forever; and the refusal now prints a count and the providers instead of raw session ids, which our own marker rationale treats as one tab-id hop from a credential. * test: pin structured-session close on the folder-workspace removal path The folder and orphan removal callers now pass closeStructuredSessions so a live structured session is closed best-effort rather than left bound to a workspace Orca has forgotten. These three exact-args characterizations describe that call and had not been updated. * fix(orchestration): stop the structured worker-read cursor misdelivering silently `worker-read --source transcript` for a structured worker fingerprinted only the oldest item's id, so `source_changed` fired when the window slid off the front and could NOT fire when the page's contents changed under a stable oldest item — which is the normal case, because the journal is a reduced, mutable timeline. A `running` tool item gains its `[tool result]` at its original sequence once later items exist, the 60ms delta coalescer revises a message in place, settlement can rewrite an item smaller, and a pending approval projects to null until it resolves and then appears in the MIDDLE of the array. Two silent failures followed, both returning ok. Omission: a caller handed a coalesced `hel`, resuming past it, never received the revision to `hello world` — the same defect we refused to ship on the terminal read path, already shipped here. Duplication: a resolved approval inserted ahead of a saved index, which was still accepted, so the caller re-read content it already had. The blast radius is the coordinator polling loop, the verb's primary consumer. The anchor is now the oldest item PLUS every item whose projected message sits below the caller's position, by id and revision. `createWorkerOutputSourceIdentity` already takes an arbitrary string array and the cursor is already opaque base64url carrying its own position, so neither the wire shape nor the `source_changed` contract changes. Prefix-scoped rather than whole-page deliberately: fingerprinting every item on the page would flip the identity every 60ms with the coalescer window during an active turn, making the cursor unusable exactly while the worker is working — that trades a silent bug for a useless verb. Tail growth the caller has not read cannot invalidate; any change to what it already holds does. Position-dependence is safe because `p` rides in the same opaque payload as the identity, and the returned cursor is stamped with the identity of its own end, which is precisely what the next read recomputes. The frozen archive keeps a constant identity: no item can be revised under a caller there, so it has no prefix to fingerprint. Both silent shapes are pinned across a page boundary with the journal mutating between reads — a static-journal test passes either way. Two ablations at the real call site: reverting to the oldest-item-only anchor turns both red, and widening the prefix to the whole page turns the tail-growth case red, which is what proves the scoping is real in both directions. * docs(orchestration): stop the structured terminal-read refusal recommending a dead end The refusal told a peer to "page it with `orca orchestration worker-read --source transcript`", which is wrong three ways and this file said so itself: its own header explains that this verb exists BECAUSE `worker-read` demands a dispatch id and coordinator standing "a peer does not have" — and then the refusal sent that same peer there. The verb it named is also a window index over the same bounded page, so it is not a paging answer even for a caller who can reach it; under load it now answers `source_changed` on most polls, which is better than the silent hole it had before but still not what the sentence promised. The refusal now says what actually works — the tail is bounded and newest-last, so poll it and diff — and names no alternative, because there is none. That is the honest framing: a durable cursor is not achievable here at all, rather than blocked on the wire shape. The journal is a reduced, MUTABLE timeline: an item's projected text changes at its original sequence after later items exist, the delta coalescer revises repeatedly, settlement can rewrite an item smaller, a pending approval renders as nothing and then as something, and `sequence` resets on epoch rollover. No index, numeric or opaque, survives that. So the docstring's "pagination with a real anchor lives on `worker-read --source transcript`" is gone too — there is no real anchor there — and the file now records why no windowed alternative should be built later: a broken cursor fails UNSAFE, as a silent hole in a poller's output, while diffing a bounded tail fails safe as a harmless re-read, and a second paging-shaped verb would invite the PTY assumptions this one cannot honour. The test asserted the old advice, so it now pins the contract instead: the refusal explains the working approach and must never name `worker-read`. `worker-read --source transcript` remains a good bounded snapshot for a coordinator reading a worker it dispatched; only the "or page it with" clause was false. * fix(i18n): add the missing worktree-removal agent-session refusal string The structured-session removal refusal introduced a translate() key with no en.json entry. Nothing local catches that: typecheck passes, and the full suite passes, because a missing key falls back to its inline default at runtime. Only verify:localization-catalog fails on it, which is why CI's static analysis reddened on a branch that was green everywhere else. Fallback wording mirrors the sibling unstoppedPtyLive string, since the two refusals differ only in what is still running and what Force Delete does to it. * test(codex): expect the no-identity marker on an unregistered structured child The refuse-rather-than-guess marker landed after these expectations were written, and all three assert exact env equality on the unregistered path — the one branch that now carries ORCA_STRUCTURED_SESSION. One of the two files was added by this same branch, so this is a self-inflicted drift; the other predates the branch and was broken by it. The marker's presence is still pinned positively by structured-worker-child-identity-env.test.ts and the CLI's orchestration-structured-session-no-identity.test.ts, so relaxing these three exact-equality checks loses no coverage of the security property. * fix(orchestration): require exit evidence before settling structured close --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
2dd3958339 | test: distinguish external retention from owned worker recovery (#19190) | ||
|
|
79eb66608a | test: retain paired browser value from successful poll (#19189) | ||
|
|
6c8ce54ad8 | test: publish restored snapshot before draining its held FIFO (#19186) | ||
|
|
1848855515 |
test: enable software WebGL for Linux CI headful specs (#19001)
* test: enable CI WebGL and route GPU-dependent regressions * test: retain headful atlas cases in terminal rendering goldens * test: reuse golden command in project coverage assertions |
||
|
|
e9af947035 |
test: confirm running-command prompts when closing tabs (#18965)
* test: wait for rendered tabs and handle busy close confirmation * test: wait for create-menu item click actionability * test: settle initial terminal focus before create-menu actions * test: capture menu focus events for Linux CI diagnosis * test: remove menu diagnostics after identifying deferred layout focus * test: check Markdown menu dismissal after editor readiness |
||
|
|
b3acef218a | test: verify imported projects through the virtualized sidebar (#19003) | ||
|
|
4be1c01c42 | test: await rendered remote agent placement before checking mirrors (#18983) | ||
|
|
357a4d4920 | test(e2e): scope paired preview link checks to confirmation (#18924) | ||
|
|
1e301ab1df |
test: cover native Wayland Hangul in isolated CI (#19174)
* test: exercise native Wayland Hangul in isolated CI session * test: wait for nested compositor socket before selecting IBus * test: align Wayland IBus discovery with GNOME environment filtering * test: assert Wayland launch and register native Hangul evidence |
||
|
|
af5918a254 | test: use host-qualified paired palette row identities (#19175) | ||
|
|
fc37958b45 |
fix: release floating terminal WebGL contexts while closed (#19000)
* fix: release floating terminal WebGL contexts while closed * test: pin retention polarity through a real PaneManager Replace the prototype-surgery fake with a constructed PaneManager so the suspend path exercises real constructor state, and add the retain-branch case so an inverted default cannot pass silently. De-shadow `window` in the system-resume e2e main-process callback. |
||
|
|
e28b15928a |
fix: avoid credit deadlock during large SSH PTY recovery (#19026)
* fix: avoid credit deadlock during large SSH PTY recovery * test: restore bounded SSH flood recovery coverage * test(relay): pin the recovery fence to the accepted checkpoint The oversized-tail cases asserted that the drain completes, but not that recoveryEndSu lands on the checkpoint, so passing the pre-rotation snapshot (which carries the old client's window and a stale creditedEndSu) fenced below the checkpoint and still passed. Assert the fence value, narrow boundedPtyRecoveryEnd to the three fields it reads, and cover the exact one-window boundary that separates a live drain from an ordinary fence. |
||
|
|
57d4f63ac3 |
test: refresh palette identities and structured-session journal fixtures (#19165)
* test: persist palette fixture names across inventory refresh * test: locate palette workspaces by host-qualified identity * test: supply journal activity clocks in branch-rename fixtures |
||
|
|
08b96ed1b3 |
Seed Cmd-J filter from sidebar scope (#19036)
* feat(palette): seed Cmd+J filter from sidebar show scope When opening Cmd+J, the palette's host and project filters now initialize from the sidebar's current Show scope, so results match the user's sidebar view. The palette can still be cleared or changed per open; sidebar never reads back palette filters. * refactor: pass app state to palette filter builder Let the builder function extract the sidebar scope it needs instead of requiring callers to destructure and pass individual properties. This reduces coupling and simplifies the data flow through the palette initialization lifecycle. * Make palette filter repo-granular to preserve sidebar scope Filter options now list individual repositories instead of grouping multi-repo projects into single rows. This preserves the exact repository scope shown in the sidebar when opening Cmd+J, rather than widening selections to entire projects. Removes per-field selection cap and stale-value reconciliation, simplifying the filter lifecycle. * Clarify filter naming and seed from sidebar scope on palette open - Rename projects→repositories in PaletteFilterModel for semantic accuracy - Rename rawFilter→filterState for clearer intent - Initialize filter from sidebar scope in local state, refresh on open - Remove redundant filter reset from selection lifecycle * Seed Cmd-J filter from sidebar scope and reset on close The palette now opens with the sidebar's host and repository scope applied. Filter changes are temporary: closing discards them, and reopening reseeds from the sidebar's current state. - Repository filtering is now granular (individual repos) - Support shared repository IDs across multiple hosts - Disambiguate duplicate repository names by path * Add comment clarifying Projects terminology Document the naming convention for repository-granular filter choices to help future maintainers understand why "Projects" is used as the user-facing term. * Remove redundant Escape press from worktree palette filter test |
||
|
|
0c33f58e8a |
fix(ssh-relay): daemon owns the endpoint credential; a losing start never rotates it (#19052)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 19 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$962 | $\color{#cf222e}{\Huge{\mathbf{−}}}$136 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$826 |
| Prod | 18 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$295 | $\color{#cf222e}{\Huge{\mathbf{−}}}$116 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$179 |
<!-- /orca-pr-loc -->
## Symptom
Live 2026-09-05 (Orca 1.4.198 client, Ubuntu host): both relay processes `kill -STOP`ped for 20 s, then `-CONT`. The client redeployed while the host was frozen. Its fresh daemon lost the socket bind (`Socket path already in use`) but had **already rewritten** `relay-<id>.sock.credential`. The surviving daemon kept its in-memory credential, so every later `--connect` got `Endpoint credential mismatch; closing socket`, then `Grace started … timeoutMs=0 … ptys=1, clients=0` every ~20 s, forever. Only a manual `kill -TERM` cleared it. Receipts: `review-archive/orchestration-v3-pr16904/smoke-receipts-t012b/E16,E17,E18,E24`.
Three independent defects kept the wedge alive; each is fixed at its own seam.
## Fix
**1. The relay daemon owns credential publication (race-free under two concurrent starters).**
`relay-daemon.ts` binds the socket first, then publishes via the new `src/relay/relay-endpoint-credential-publication.ts`: adopt a valid pre-existing file (older clients still pre-write), else mint 32 random bytes and write temp+rename at 0600. A start that loses the bind exits inside `listen()` and never reaches the file. Why this option and not restore-on-loss or a client-side write: the only process that can *prove* ownership is the one whose `listen()` succeeded, and that proof is atomic with the bind. The client-side pre-write (`ssh-relay-endpoint-credential.ts`) and the launch-command `chmod 600`/`icacls` are removed on POSIX and Windows. The racing test also exposed that macOS reports a mid-bind collision as `EEXIST` rather than `EADDRINUSE`; `relay-socket-ownership.ts` now treats both as "held or stale".
**2. The client distinguishes "no daemon" from "daemon present but not answering", and never rewrites.**
A credential refusal is now typed on the wire: the daemon replies `orca-relay-handshake-credential-mismatch` (same frame type, no new opcode) and the bridge exits **43**; `waitForSentinel` maps it to `RelayCredentialMismatchError`, which the takeover treats as handshake-refusal evidence exactly like exit 42. A relay that holds the endpoint but **never refused** (the stalled-host shape: kernel backlog accepts the probe, handshake gets no answer) is now `RelayEndpointUnresponsiveError`, routed to the relay-lost backoff instead of the terminal Reset Relay path. Silence is not a decision (`docs/reference/ssh-execution-boundary.md`).
**2b. Deploy honours the verdict.** The 40 s live run exposed that the `--connect` catch block in `deployAndLaunchRelay` predates the incumbent probe and swallowed both verdicts as "probe failed, launch fresh", so a fresh daemon was still launched over the live one (it lost the bind by luck, which is exactly the collision in the incident). Held and Unresponsive now propagate; the session backs off on Unresponsive and surfaces Reset Relay on Held. Red-first in `ssh-relay-deploy-incumbent-verdict.test.ts`.
**3. The daemon cannot be wedged by a rotated file, because nothing can rotate it.**
The credential lives in the content-hashed relay dir, and after (1) the only writer is the daemon that owns the socket, so the "file changed under a live daemon" state the incident depended on is no longer reachable in-product. The credential is therefore fixed for the daemon's lifetime, as a plain secret should be. A hand-edited file is refused with the typed reply until restored (tested). Startup adoption of a pre-written file applies an owner-only + same-uid rule (review finding): anything else is replaced by a fresh mint. An earlier revision of this PR also re-read the file on mismatch and adopted it; that was removed as unreachable machinery that turned the credential into a per-handshake file-ownership check.
**3b. Fail closed between bind and publication.** A client that arrives after `listen()` resolves but before the credential is set is refused, not admitted as `unproved`. Nothing can be delivered in that window today; the guard makes the boundary structural instead of an event-loop ordering fact. Red-first in `relay-reconnect-listener-credential-gate.test.ts`.
**Wire compat.** New optional handshake reply only; an old `--connect` hits `Unknown handshake type` and exits 1 pre-sentinel, which it already treated as a generic failure. New daemon adopts an old client's pre-written file; new client still passes `--credential-file` so an old daemon reads it as before. Absence of exit 43 is never used as evidence.
**Also.** `terminal create` on a reconnecting SSH host now says what to do instead of a bare `No PTY provider for connection "<id>"` (prefix preserved; the renderer matches it).
## Tests (red first)
- `src/relay/subprocess.test.ts`: two `--detached` starts race one socket + credential file → exactly one reaches the sentinel, loser exits 1 with `Socket path already in use`, file valid + 0600, a `--connect` reading it reaches `relay.status` and reports the winner's pid. Red before (both starters died: daemon required a pre-existing file), green 6/6 after.
- `src/relay/relay-endpoint-credential-publication.test.ts`: mints after bind; adopts a pre-written 0600 file; replaces a pre-written 0644 file with a fresh mint; refuses a stale credential with exit 43 while still serving the real one, and keeps refusing a rewritten file until it is restored.
- `src/relay/relay-reconnect-listener-credential-gate.test.ts`: a client in the bind-to-publish window is refused and never attached; after publication the right credential is accepted and a wrong one refused; a daemon launched without a credential file is not gated. Red without the guard.
- `ssh-relay-deploy-incumbent-verdict.test.ts`: live-but-silent incumbent → `RelayEndpointUnresponsiveError`, refused → `RelayEndpointHeldError`, and in neither case is `--detached` launched; a failed `test -S` probe still launches fresh. Red 2/3 without the deploy change.
- `ssh-relay-deploy-helpers.test.ts` (exit 43), `ssh-relay-endpoint-takeover.test.ts` (refused → Held even with no `lsof`; silent → Unresponsive, nothing unlinked or signalled), `ssh-relay-session-terminal-error.test.ts` (Unresponsive → `onRelayLost`, not terminal). Deploy/namespace/native-deps tests updated to assert the client writes **no** credential.
## Live proof
New `tests/e2e/ssh-docker-relay-stall-credential.spec.ts` (claimed in `run-ssh-docker-e2e.mjs` and PR source routing), two cases: `kill -STOP` every relay pid in the container, send input during the freeze, hold **20 s** (the incident's duration, which races the mux liveness timeout) or **40 s** (past it for sure), `kill -CONT`; assert status back to `connected`, same pty, same daemon pid, same credential inode and content, relay.log did not shrink (a relaunch truncates it) and has zero `Endpoint credential mismatch` / `Socket path already in use` lines, in-stall input delivered at most once.
Run output (local, fixture image `orca-e2e-ssh-relay:3a864c665ba2cefd`, `ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 … --project electron-headless --workers=1`, head `c2c20fd994`; re-run identically on the final head after the credential-lifetime change, 2 passed (1.7m), same annotations, and the bind-to-publish refusal never fired):
```
✓ keeps the same daemon and credential across a 20s relay freeze (38.3s)
relay-processes-stopped: 2 relay-processes-continued: 2
bridge-pids-before-after: 480 -> 480
socket-clients-accepted-before-after: 1 -> 1
in-stall-input-delivered: 1
✓ backs off and reattaches, never relaunching, across a 40s relay freeze (57.5s)
relay-processes-stopped: 2 relay-processes-continued: 4
bridge-pids-before-after: 480 -> 1202
socket-clients-accepted-before-after: 1 -> 3
in-stall-input-delivered: 1
2 passed (1.6m)
```
Client log in the 40 s case shows the new path end to end: `Relay channel lost … reconnect attempt 1/6` → `Socket probe result: "ALIVE"` → `Socket reconnect failed … Relay failed to start within 10s` → `Relay endpoint incumbent: … verdict=live evidence=accepted-connection holders=unenumerable` → `Failed to re-establish relay … A relay still owns … but did not answer the handshake … Orca will retry` → `reconnect attempt 2/6` → `Reconnected to existing relay via socket`. The 20 s case never left the frozen bridge (same bridge pid, one accept), so it exercises the "silence is not death" side of the same race. The 20 s case passed 6/6 across the session; the 40 s case was red on the prior head (`Socket path already in use` + `Startup failed: listen EADDRINUSE` in relay.log from the swallowed verdict) and is green after 2b. Before the fix the same injection produced a fresh daemon that rewrote the credential and a survivor refusing every client.
The `relay-processes-continued` count exceeds `stopped` in the 40 s case because the timed-out client's `--connect` bridge and the loser-side processes are parked behind the frozen listener when `CONT` runs; they exit on their own once it resumes.
## Gates
`pnpm test src/relay src/main/ssh` 332 files / 3884 tests pass · `pnpm typecheck:tsc:node` clean · `check:code-quality:changed` 0 findings · `check:react-doctor:changed` 0 findings · `pr-e2e-gate-contract.test.mjs` 42 pass · no lint disables or max-lines bumps added.
## Noted, not fixed here
- `terminal list` `orphaned:false` / `terminal close` `ptyKilled:true` for a pane whose relay is gone (`orca-runtime-stop-explicitly-closed-tab-ptys.ts`): different seam, `@ts-nocheck` characterization-covered file.
- On a host with no `lsof`, a stalled relay still cannot be enumerated as the holder; it is now retried rather than declared held, but a relay frozen past the backoff budget still ends in the existing "reconnect manually" banner.
|
||
|
|
06a607a1d7 |
feat(orchestration): make multi-agent workflows durable (#16904)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 225 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$21666 | $\color{#cf222e}{\Huge{\mathbf{−}}}$2820 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$18846 |
| Prod | 348 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$17107 | $\color{#cf222e}{\Huge{\mathbf{−}}}$4706 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$12401 |
<!-- /orca-pr-loc -->
## ELI5
Orca now treats orchestration like a durable control plane instead of inferring success from terminal keystrokes. Agents can tell whether a prompt was accepted or a turn started, replay an ambiguous request without sending twice, and recover coordinator mail after a crash. Completed workers can be inspected, released, or retained, and their panes no longer auto-resume as if the work were still running.
## What changed
- **Run receipts** from `run-create/use/current/show/list` are the row without routing plumbing (`home_database`, `coordinator_pane_key`) and without the duplicate `binding` object.
- **`terminal send` receipts are honest and idempotent.** `input_accepted` and `turn_started` are the only stages; `--wait-submit` observes without resending; `--retry-request <uuid>` replays the exact request against the same process incarnation. A transport timeout keeps the retry ID; only a different runtime answering strips it. Value-less or non-UUID `--retry-request` is rejected on the CLI and the SSH shim.
- **Mailbox delivery is committed before wakeup.** Pointer writes are staged in the DB before any PTY byte, replayed once after restart, and never emit a naked Enter. The watermark that parks concurrent deliveries is released with the DB reservation. Restart rescans pointer-pending and `dispatch:` mailboxes.
- **Lifecycle is a guarded transition graph** (`lifecycle-transition.ts`) with a table-driven test over every caller edge. Task reopen/overturn stays in the public contract. A PTY exit during `worker-stop` is the stop succeeding, not a failure.
- **Worker lifecycle CLI:** `worker-start` (`--spec` creates Task + attempt in one call), `worker-show`, `worker-read` (provider transcript first, bounded terminal fallback with a typed reason, local/WSL/SSH), `worker-stop`, `worker-abandon`, `worker-release`, `worker-retain`, `worker-list` (rowid-fenced pagination, fleet liveness, `attention`, literal `nextAction`).
- **Release is an explicit ownership table** (`decideWorkerTerminalRelease`): only an `owned` resource can be settled, the archive is mandatory where reachable, and an owner whose process is proven exited can always get out of `retained` via `archive_status: unavailable`. User-taken-over, external, and transferred panes stay retained.
- **Settled-worker resume fence** (folds in #17651): a settled dispatch whose pane is still open is fenced at settlement, on stop/abandon/exit, and at startup; lifted on release, retain, takeover, and pane reuse.
- **Liveness is `live` / `unverifiable` / `exited` only**, from execution-host evidence. Fleet projection reads the evidence clock, not the relay delivery clock. A host-certified exit outranks the worker's settled state. `unverifiable` never authorizes stop, abandon, retry, or release, in code or in the guide.
- **Federation:** structured reads negotiate by `method_not_found` so every shipped host keeps transcript-first output; exited remote workers are closed before being reported closed; epoch fencing holds across peer restart, downgrade, and pairing rotation; no per-second forced capability probe.
- **Schema v35:** repairs databases stamped v34 by the pre-fix branch (mailbox_handle default, index predicates), drops the write-only `lifecycle_transition_receipts` ledger and five never-read v31 identity columns.
- **Schema v36:** `dispatch:<id>` mailboxes get a real consumer generation on `dispatch_contexts` and `remote_dispatch_attachments`, bumped and fenced in the same transaction on every re-attach (manual inject, worker-start, federated attach). A stale worker whose Dispatch moved to another process now gets `consumer_fenced` instead of silently acking the new worker's Delivery. Run mailboxes already worked this way.
- **Schema v37:** `dispatch_contexts` records its creator (`creator_handle`, `creator_pane_key`), so a coordinator's context-only self-dispatch is bookkeeping rather than a nesting parent; before this, one self-dispatch made every later `worker-start` from that coordinator fail the depth cap. Pre-v37 rows keep counting (fails closed).
- **Dispatch-mailbox ownership is checked, not inferred.** A `check` from a process whose pane no longer holds the Dispatch, or whose last Attempt was abandoned/failed and moved to another terminal, gets `consumer_fenced` instead of an empty inbox that reads as "no mail yet". `--peek`/`--all` stay readable. A paneless caller still gets `stable_pane_required` with the rebind recovery.
- **Liveness certification is stricter:** a `process_exited` stage whose termination reason is `unknown` (a stop that was issued but never observed) projects `unverifiable`, not `exited`. Federated `worker-show` carries the execution host's verdict and host kind instead of a local guess. A live, ready worker with nothing pending has `nextAction: none` rather than pointing at the `worker-show` that produced it.
- **Wire:** `workerShow` keeps `dispatch.task_id` next to `taskId` for shipped CLIs. `ask --json` uses the standard `{ok, result}` envelope like every sibling verb.
- **Migration start-version detection** treats the two v32 recovery columns as versioned. Before this, every shipped database stamped below 32 resolved to the v6 floor and replayed the whole chain (the v23 backfill synthesized 68 phantom retained workers on a real v30 profile). Verified on a copy of a real 62 MB v30 profile: starts at 30, no row delta, integrity ok, 11 ms.
- **Skill guide** rewritten as a ≤200-line kernel plus seven references, to the outcome-first standard (Result / Done / Safe failure first, conditions not case lists, one done bar, references loaded at the point of use). The canonical loop uses `worker-start --spec`, names `worker-list` for completion accounting, documents `--retry-request` / `request-show` / `--wait-submit`, and requires positive evidence before any stall action. The other seven guides get the same treatment in #18724, split out so this PR stays orchestration-only.
- **`rpc/methods/orchestration-*`** (126 flat files) regrouped into `orchestration/{worker,federation,messaging,runs,gates}/`.
## Why
User reports showed the same boundary failures: false `agent_prompt_stalled` causing duplicate sends (#15180), coordinators unable to trust screen scrapes, cold-parked terminals receiving a pointer without the submit, settled workers accumulating as live tabs and auto-resuming after restart, and no way to tell a stalled worker from a working one.
## Linked issues
Fixes #15180. Fixes #17935 (orchestration skill description is 866 characters; a guard now caps every bundled skill at 1,024). Supersedes #17651 (fence folded in). Advances #16660, #16522, #14907, #13047.
## Review record
This PR was reviewed adversarially after revival: eight independent lenses (lifecycle, mailbox, send, worker, federation, transcript, complexity, live ergonomics), each required to prove findings with a failing test. That produced 16 proven blockers, all fixed with red-then-green regression tests, followed by two re-review rounds and a third fix wave that caught 3 regressions introduced by the fixes and 7 fixes that missed their target; all closed. A final pass (five lenses incl. a live built-runtime smoke, then a re-review of the fix wave) found and fixed seven more, chiefly the stale-worker mailbox steal, the self-dispatch depth wedge, and the unproven-exit certification. Three independent Codex (gpt-6-astra) passes followed: the first found nothing new, the second found and fixed 3 defects (task-status reachability, WSL-local host classification, peer-capability epoch), the third found and fixed 6 (production PTY controller never installed settled writes, ambiguous in-flight pointer failures allowed duplicate replay, SSH/relay deadlines cut off a valid `--wait-submit`, stop-vs-exit race during inspection, and two release-recovery paths for vanished or exited terminals). The full record (findings, proof tests, triage, declines with reasons) is archived outside the repo.
**Rework after the live smoke.** A first live cross-host run on the shipped adhoc build (this Mac, a paired Windows host on the same build, a paired Mac on 1.4.195, and an SSH host) found a P1: a running local worker read `unverifiable`/`missing_status` because the fleet snapshot rows lacked the terminal handle the matcher keyed on. A 59-row failure table over every bug fixed during review showed the same two classes recurring: a fact dropped in transit through optional fields, and two authorities for one fact. Two blind designs (Opus, Codex) converged on the same mechanisms, and the scoped tranches landed here with red-then-green seam tests from the real producer to the real consumer, faults injected only at the transport or hook-ingest boundary:
- **Settlement (data-loss class):** one three-valued `WriteSettlement` (`accepted | refused{reason} | unverifiable{reason, bytesHandedToTransport}`) from the SSH multiplexer through daemon client, providers, controller, to pointer staging. No boolean, no rejection-as-third-state. The two silent degrades that fabricated a handoff are deleted; a provider that cannot settle refuses before any effect. Pointer text and Enter share the contract; a partial flush is `unverifiable`, never `refused`.
- **Evidence identity (false-liveness class):** fleet agent-status evidence is a tagged union (`binding: worker | pane | unresolved{reason}`, `clock: observed | delivery`) minted once at ingest, so a hook row captured on one process incarnation can never bind to a later dispatch on the same pane. The matcher's `!worker.paneKey ||` defaults are gone. One host-scope parser replaces two.
- **Small pre-merge items:** `capability_unsupported` from an old peer is no longer relabelled `host_unavailable`; a producer census test asserts every agent-status consumer path projects a pane-only hook row as `live`.
Two ergonomics defects the second live run surfaced on a real database are fixed here too: a pre-v3 dispatch already marked `completed` projected as `outcome_unknown` / `requiresAction: true` forever (three copies of the outcome ladder disagreed on legacy rows; now one resolver, legacy `completed` reads `succeeded` with nothing to act on, legacy `failed` stays actionable on the failure), and an unscoped `worker-list` enumerated the entire database (now defaults to the Run bound to the calling terminal, `--run` overrides, and the receipt's additive `scope` field says which).
A third live round on the shipped adhoc build of `b082443e1f` (same four hosts) plus an unscripted run in the user's own prompt style (a plain Claude Code shell, `/orchestration`, three workers, zero errors, bound-Run default confirmed) found two more branch defects, fixed with red-then-green tests: a worker freshly started on a paired server projected `unverifiable`/`host_indeterminate` with `requiresAction` for ~3 minutes, including after its own `worker_done`, because the host's federation observation returned `missing_liveness_verdict` for any PTY the liveness register had not yet swept (the host now reads a connected pane it owns locally as `live`; disconnected or SSH-scoped panes stay `unverifiable`); and six pre-v3 completed rows still carried an `input` category because settling through the task-status path or `failDispatch` never closed the Dispatch's pending question threads (both paths close them now, and schema v38 closes threads already pending on settled rows). The guide's `worker-start` examples now show `--model sonnet`, since an omitted model inherits the launcher's default.
A Codex adversarial pass on the tranche diff found one real design hole (identity minted at read time instead of ingest, now closed) and two daemon settlement paths that threw instead of settling (fixed). Two `@ts-nocheck` runtime mixins on these paths were extracted into checked modules; the repo-wide `@ts-nocheck` count is unchanged at 171.
Deletions during review: ~1,900 lines (write-only ledger, unread columns, dead v1 archive path, test harnesses shipped in prod, duplicated liveness and state-machine copies, self-capability checks that were compile-time true).
## Testing
- `pnpm typecheck:tsc:node|cli|web` clean
- `pnpm run check:code-quality:changed` 0 findings; `check:react-doctor:changed` 0
- `pnpm verify:bundled-skill-guides`, `verify:skill-bundle-manifest`
- full `pnpm test` on the integrated head: 72,332 pass / 292 skipped; the only failures were three non-PR files (two zsh live-shell suites hit a node-pty spawn-helper ENOENT while a concurrent native rebuild ran, 44/44 in isolation; `release-checkout.unit.test.ts` is a known 30 s load timeout that passes in isolation on `origin/main` too).
- CI on
|
||
|
|
3be526c5e6 |
test: cover SSH reattach replay and enable deterministic Codex CI (#19106)
* test: cover SSH replay replies and run deterministic Codex restore scenarios * test: register replay probe unit command in reliability gate |