mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
2b0ee06205154fb69c4d2b2cffde877f8f7638db
9393
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2b0ee06205 |
docs(env-recipes): warn that snapshotting a started runtime bakes its identity (#17001)
Snapshotting a VM on which `orca serve` has already run captures the runtime's user-data dir into the image. Every VM booted from that image then shares one pairing identity and one agent-session-authority key, which defeats the per-device token design. Confirmed by booting two VMs from one such snapshot: both emitted identical deviceToken and pairedDeviceId. Adds the rule to the base-snapshot section and repeats it for the agent-auth layer, which is the likelier place to start the runtime by hand while smoke-testing. Says to delete the whole user-data dir rather than a named file list, since that list drifts as Orca adds state. |
||
|
|
a4762f1c1d |
docs(headless-server): fix package list, extraction perms, and ldd command (#17002)
* docs(headless-server): fix package list, extraction perms, and ldd command Three fixes to the headless Linux server guide, each of which stops a first-time setup from working. The prerequisite list installed only CLI tools and Xvfb, none of the shared libraries Electron links against. On a minimal server or container image `orca serve` then fails before Electron starts. Adds the library set, plus the unsuffixed package names for releases that predate the 64-bit time_t transition. The guide tells you to run --appimage-extract and, separately, to run the service as a dedicated non-root user with the install directory root-owned. Those two halves combine badly: extraction leaves squashfs-root as drwx------, so the service user cannot traverse it, and chmod 755 /opt/orca does not reach inside. Adds the missing chmod to both places. The troubleshooting step said to run `ldd squashfs-root/orca`. The Electron binary is orca-ide, and ldd on a path that does not exist prints nothing and exits cleanly — a clean-looking result in exactly the situation where you are hunting a missing library. * docs(headless-server): correct the t64 substitution failure mode The mixed-list warning named the wrong mechanism. Old names mostly still resolve on 24.04, because each renamed package declares Provides: its unsuffixed name. The exception is libasound2, which liboss4-salsa-asound2 in universe also claims — apt refuses to choose between two providers and aborts the whole install line rather than silently installing a shim. Also pins libfuse2t64 as definite rather than possible, and widens the libfuse2 line to cover 20.04, which is in the support matrix. |
||
|
|
caef20fec8 |
fix(gitlab): paginate TaskPage issues beyond 50 (#13538)
Co-authored-by: Neil <neil@stably.ai> |
||
|
|
83320bff9e |
fix(pty): stop fish printing a test error when codex is absent (#16893) (#16923)
fish splices an unquoted command substitution that produces zero words out of the argument list entirely, so `test (type -t codex 2>/dev/null) = file` became `test = file` whenever codex was not resolvable. fish's test rejects that as malformed and wrote "test: Missing argument at index 3" to stderr on every pane and agent launch for fish users without codex installed. Capture the substitution into a local first, mirroring the bash/zsh variant, then clear it so the guard leaves no variable behind in the user's session. Quoting in place is not a fix: fish never performs command substitution inside double quotes, so the wrapper would silently never be installed. Wrapper behavior is unchanged for every codex state (file, function, alias, absent) on fish 3.1.0 and 4.7.1, and no other shell's preflight is touched. Combines #16923 and #16928. Co-authored-by: erishforG <erish2150@gmail.com> Co-authored-by: Fuzzwah <rob.crouch@gmail.com> |
||
|
|
8fa1b3c16c | test(release): bound Windows skill budget fixture (#16992) | ||
|
|
86b770e448 |
fix(release): trust Linux floor workspace (#16988)
* fix(release): trust Linux floor workspace * test(release): ratchet workspace trust scope |
||
|
|
c4b39295c1 |
style: format codebase (#16935)
* style: format codebase * style: format codebase * refactor: extract skill install dialog footer and content Extract footer and content sections from SkillInstallDialog and SkillInstallManagementDialog into separate components for improved maintainability and clarity of component responsibilities. |
||
|
|
59515beb70 |
fix(release): recover immutable patch validation gates (#16984)
* fix(release): recover immutable patch validation gates * test(e2e): locate wrapped terminal file links * test(e2e): keep sibling file links on one terminal row |
||
|
|
4bb337741c |
feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042) (#16868)
* feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042) Field instrumentation to name the writer behind regular-text-renders-bold: - metric-weight-change crumbs at the writePaneMetricOptions funnel (prev/next/reason; weights never change in normal operation) - terminal-weight-parity-mismatch audit on every visibility resume - sentinel weightProbe capture fields: live options vs atlas captured config vs renderer-buffer bold census - Cmd/Ctrl+Shift+click unconditional capture (no divergence gate, no recovery) for states the missing-ink detector cannot see - patched addon-webgl ctx.font readback probe: detects failed font assignments that rasterize glyphs at a stale weight * fix(terminal): treat canvas weight-700-serializes-as-bold as a match in the atlas font probe Found by live validation: Chromium's ctx.font getter normalizes numeric 700 to the keyword 'bold', which made every legitimate bold rasterization count as a failed assignment (124 false positives in one session). * chore: update patch hash for the font-probe normalization fix * fix(terminal): bound bold glitch diagnostics * fix(terminal): cover serialized WebGL probe state * feat(settings): hidden staff toggle to arm terminal render diagnostics Replaces the reserved hidden-experimental placeholder slot with a real switch (Shift-click the Experimental sidebar entry to reveal). It arms and disarms the render-desync capture sentinel live — no localStorage incantation, no reload — for the bold-glitch investigation. The passive probes stay always-on; only the capture gestures are gated. * fix(settings): make render diagnostics disarm exact * chore(settings): rename hidden group to 'Hidden experimental settings', drop its description * feat(settings): unlock hidden experimental group via Option-click on the Experimental page title Replaces the Shift-click-sidebar unlock with the Updates-header idiom: Option-click the Experimental page title toggles the hidden group. Removes the now-unused click-modifier plumbing from the settings sidebar. |
||
|
|
fc8c981103 | fix(browser-preview): enforce canonical runtime grants (#16975) | ||
|
|
6c58038f4a |
fix(session): defer session writes suppressed by a direct-SSH apply (#16969)
The debounced session writer cleared its pending changed-field set whenever the persist gate was shut, after it had already advanced its identity-based detection baseline. Because detection is `prev[key] !== next[key]`, a field dropped there could never be re-detected, so a mutation made during an SSH apply (up to 30s plus a 1s tail, on every connect and reconnect) was lost for good — including a closed-tab tombstone, which is exactly the state that stops the host from resurrecting the tab. Keep the pending set instead, and add a one-shot wake-up from the gate owner so a deferred write is not stranded when the suppression tail expires with no store update behind it. |
||
|
|
f5fb60b13a |
fix(terminal): a restarted relay must not hand a new tab a dead PTY's exit (#16970)
A redeployed SSH relay renumbers its PTY ids from pty-1, so a freshly spawned PTY can be handed an id a dead one used to own. The renderer's pre-handler buffer is keyed on that id alone, so `registerExit` found the dead PTY's buffered exit and reported the brand-new shell as `exitedBeforeAttach` — the pane never bound a PTY and the tab came up blank forever. Date every buffered record with a monotonic sequence and fence a fresh spawn against it: state recorded before the spawn request left the renderer belongs to the id's earlier owner. State recorded after it is kept, so the real pre-attach race (a shell that dies instantly, or writes before the pane registers its handler) still works. Makes ssh-lost-kill-tab-resurrection.spec.ts:178 pass; it failed in SETUP. |
||
|
|
39535a8f1f | fix(ui): rename orphan task action (#16976) | ||
|
|
91e7cd088a |
refactor(terminal): collapse the five inline remote-execution-host PTY idioms (#16967)
`isRemoteExecutionHostPtyId(id)` (= paired-runtime PTY or direct-SSH app PTY, i.e. "this request crosses a link") was named in #16941 but only used at its own call site. Five inline copies of the same disjunction remained. Moves the helper up out of `pty-connection/` — three of its six call sites are parking/retention/watcher modules that have nothing to do with pty-connection — and replaces all five copies. Behaviour-preserving: both prefix-form `isRemoteRuntimePtyId` implementations (`paired-parked-terminal-restore` and `runtime-terminal-inspection`) are `startsWith('remote:')`, and `parseAppSshPtyId(x)` returns an object or null so the truthy and `!== null` forms agree. The negated site keeps its `ptyId !== null` guard (De Morgan on `!A && !B`), and the retention site keeps `!ptyId` so empty ids still bail. |
||
|
|
de73ac89c6 |
refactor(runtime-status): fold the reconnect re-probe into the refresh action (#16968)
* refactor(runtime-status): fold the reconnect re-probe into the refresh action The reconnect re-probe was never a new concept -- only one policy of the existing status refresh -- but it duplicated the store action's publish and both recovery follow-ups. Give the action a publishUnreachable option (default true, unchanged for the user-initiated check), pass false from the reconnect path, and delete the wrapper. * test(runtime-status): name the reconnect-policy follow-up tests for what they assert |
||
|
|
b1fe9075db |
fix(ssh): never claim authority over host tabs this client could not place (#16956)
* fix(ssh): a host tab row this client cannot place is unverifiable, not absent
A degraded listLineage leaves worktreesByRepo empty, so exactTargetWorktreeIds
returns nothing, every host path fails to resolve, and importRemoteWorkspaceSession
silently dropped every tab row. The apply then marked the target hydrated and
'synced', freezing that emptiness in permanently — nothing re-pulls a hydrated
target (STA-3593).
The importer now reports unplaceable rows, the apply claims authority only when
every row landed, and a bounded chain re-pulls the missing input (catalog +
lineage). On exhaustion it settles back to the pre-fix behaviour so a genuinely
unplaceable path is never left worse off than today.
* fix(ssh): keep the re-pull chain bounded, unwedgeable, and announced once
Five defects in the first cut of the chain, all found before merge:
1. the caller owned the attempt counter, so an unsolicited host push re-armed
it at 0 and the chain never exhausted - an unbounded workspace.get loop;
2. exhaustion never cleared the counter, so after one bad connection every
later reconnect re-exhausted instantly and the retry was silently dead;
3. the exhaustion check preceded the armed-timer guard, letting a concurrent
report cancel the still-pending final retry;
4. a rejected host read left no timer armed and nothing rescheduled, stranding
the target on 'pulling' and un-hydrated forever - and an un-hydrated target
never uploads again, the exact permanent degradation this design avoids;
5. exhaustion re-announced on every later report, re-marking hydrated and
rewriting status on each host push.
The module now owns the counter, resetTarget gives each connection a fresh
chain, the armed guard precedes exhaustion, the timer body always reschedules
so any failure walks to exhaustion, and exhaustion is announced once.
* fix(ssh): never authorise uploads from a picture known to be incomplete
Reversal of this branch's own exhaustion fallback, on evidence.
Hydration authorises uploads (use-app-session-persistence.ts), and an upload is
a workspace.patch of kind 'replace-session' (remote-workspace-relay-sync.ts:66)
which wholesale replaces the host snapshot (relay/workspace-session-handler.ts).
So marking a target hydrated on a picture we know is missing rows does not
'settle back to the old behaviour' - it uploads an empty projection that DELETES
the host tabs we failed to adopt. Suppressed uploads are recoverable; a wiped
host snapshot is not. That data loss is reachable on main today, because today
the apply marks hydrated immediately.
Exhaustion therefore reports 'error' and leaves the target un-hydrated, so
terminal authority stays 'unverifiable' and no upload can be built from it.
Also closes two chain-lifecycle gaps found in review:
- the callback dropped its timer guard before awaiting the host, leaving a gap
in which a concurrent report armed a second overlapping chain and could trip
exhaustion before the pending apply resolved; an in-flight guard now spans it;
- resetTarget could not cancel a callback already past its await, so a stale one
rescheduled on top of the new connection's chain; chains are now generation
stamped and a stale callback exits.
* fix(ssh): scope re-pull in-flight ownership to a generation
Two races found in review of the previous commit:
- schedule(target,'placed') cleared timer/count/exhaustion but did not
invalidate an apply already in flight. When that apply later resolved
'unplaced' its generation still matched, so it started a fresh chain from
attempt 0; a host repeating placed pushes during each in-flight retry could
reset the budget indefinitely. Retirement now bumps the generation too.
- the in-flight marker was a bare Set, so a superseded callback's finally
deleted whichever marker was present - including one a newer generation had
since taken. A later report could then arm an overlapping timer while that
newer apply was still running. Ownership is now a target -> generation map and
a callback releases only the marker it still owns.
resetTarget deliberately no longer drops the marker: its owner is the only party
that may release it, and clearing it there would let a new chain arm while the
superseded apply is still running.
* fix(ssh): replay an unplaced report that was blocked by a superseded apply
Regression from the previous commit. Keeping the stale in-flight marker across
resetTarget stops overlap, but it also swallows the new connection's result: the
new apply reports 'unplaced', hits the guard because the superseded apply still
owns the marker, and the superseded callback then exits on its stale generation
without scheduling. Nothing replayed the dropped report, so no chain started -
the retry silently never ran for that connection.
A blocked unplaced report is now recorded, and the marker's owner replays it on
release. Only the stale path reaches the finally still owning the marker, so the
normal path - which released early and scheduled its own outcome - cannot replay
twice.
* refactor(ssh): drop the re-pull retry chain, keep the fix
The chain produced ten defects across review - unbounded retry, dead retry,
cancelled final attempt, wedged chain, repeated exhaustion, overlapping chains,
stale-callback cleanup, a lost report - every one in code that passed the full
suite at the time. It bought only faster recovery *within* one connection:
syncAfterConnect and applyUnsolicitedSnapshot already re-pull on the next
connect or host push, so dropping it costs a retry, never the data.
What remains is the part that was correct from the first commit: the importer
reports rows it could not place, and an apply that could not place them neither
marks the target hydrated nor sets 'synced'. Because hydration is what
authorises uploads, and an upload wholesale replaces the host snapshot, that
single rule is what stops a client from deleting the host tabs it failed to
adopt.
Status is now 'error' rather than 'pulling': with no chain pending, 'pulling'
claimed a request that was not in flight.
* test(ssh): name the upload-suppression case for the chainless design
* fix(ssh): revoke stale hydration and keep authority unverifiable when unplaced
Two holes in the previous commit, both found in review.
The hydrated set is add-only (ssh.ts), so withholding hydration only protects a
target that never synced. A target that synced cleanly and then reconnected with
a degraded lineage kept its flag, and hydration is what authorises uploads - so
it would still send a replace-session patch built from the incomplete picture
and delete the host tabs it had just failed to place. Hydration is now revoked,
not merely withheld.
The status phase was 'error'. workspace-terminal-host-authority.ts treats
'offline'/'error' on an un-hydrated target as its bounded floor and resolves
them to 'none' - which authorises seeding AND sleeping-agent resume, the exact
double-resume this gate exists to prevent. 'conflict' is the phase that actually
describes the situation, is excluded from uploads by use-app-session-persistence,
and is deliberately outside that floor set, so authority stays 'unverifiable'.
Both invariants are pinned by tests verified to fail when either fix is reverted
individually; the pre-existing tests passed with both reverted.
* fix(ssh): drop the mismatched message on the unplaced conflict status
The phase drives the user-visible label ('Workspace sync conflict'); carrying an
'unavailable' message alongside it only risked contradicting that wherever the
message is surfaced.
* docs+refactor(ssh): correct the authority floor's premise, drop a dead wrapper
Two findings from the post-merge correctness sweep.
The bounded floor in workspace-terminal-host-authority.ts justified itself on
'remoteWorkspaceHydratedTargetIds is add-only, clearRemoteWorkspaceHydrated has
no production caller'. This branch adds that caller, so the premise is now false
and a future reader would have been misled by it. The comment records the real
consequence: a target that later lands on offline/error reaches the floor having
demonstrably answered, so seeding is authorised over live host terminals. Not a
regression - before revocation existed the same target was marked hydrated and
synced, reaching 'none' sooner - but the floor should learn to tell a revoked
target from one that never answered. Flagged for the SSH-v3 consolidation, where
one authoritative liveness source replaces this pair.
applyUnsolicitedSnapshot had become a pass-through to applyPreparedSnapshot,
carrying a docstring about a re-pull chain that no longer exists. The two
collapse back into one function.
* refactor(ssh): delete the DirectSshSnapshotPlacement union
Consolidation pass finding. The union was exported and threaded through two
modules, but no production consumer ever read it: remote-workspace-ipc-bridge.ts
discards the promise's value and syncAfterConnect ignored it. 'not-applied' was
not a placement at all, only 'this apply did not happen'.
That is a parallel verdict concept with no consumer - precisely what the SSH-v3
consolidation would have had to unpick. It collapses to a local
hasUnplacedTerminalTabs boolean and a void return.
The one test that asserted the return value now asserts adoption instead, which
is the observable outcome rather than a proxy for it. All five unplaced oracles
still fail when the placement decision is forced, verified individually.
* docs(ssh): compress the tombstone rationale to its load-bearing WHY
Elegance pass. Kept the two non-obvious claims - absence cannot distinguish
'never told' from 'user closed', and uuid tab ids make a tombstoned id safe to
drop - and cut the incident narrative around them. The twice-reverted history in
remote-workspace-session-merge.ts is deliberately left alone: that one is
institutional memory about regressions, not restatement of the code.
* test(ssh): pin the fixed behaviour instead of the defect it replaced
The spec was a characterization test whose own title said 'because hydration is
marked even when adoption wrote nothing', and whose comment described exactly
the defect this branch fixes: markRemoteWorkspaceHydrated ran unconditionally
after the hydrate calls, so in the same tick adoption yielded zero, authority
flipped unverifiable -> none, and Terminal.tsx seeded a phantom tab. It polled
for hydrated === true, so the fix turned it red.
It now asserts hydrated === false, phase === 'conflict', and zero tabs - the
count measured before asserting rather than assumed, confirming the phantom seed
is gone. The phase is re-read after the tabs settle and asserted a second time,
because a conflict verdict a later apply flipped back would silently re-authorise
seeding and a single poll would miss it.
The fixme stays a fixme: this branch stops the client claiming false authority
and overwriting the host, but adoption is still the open gap. Declining to seed
is a safe wait, not the destination.
Three-legged A/B against fork point
|
||
|
|
cf5e087246 |
fix(github): bound PR-refresh alias fan-out and hosted-review cache growth (#16943)
* wip: memory-growth * fix(github): preserve newer refresh candidates |
||
|
|
8dd7d6060c | fix(release): stabilize native builds across CI platforms (#16947) | ||
|
|
bf5660df51 | feat(mobile): add causal network diagnostics (#16837) | ||
|
|
cb848647e5 |
fix(browser-preview): require explicit preview capabilities (STA-5758) (#16921)
* fix(browser-preview): require explicit preview capabilities (STA-5758) Scope document reads to approved directories, confirm external links before opening them, revoke grants with tab lifecycle, and keep document-preview session state rollback-safe across mixed client/runtime versions. * Harden document preview lifecycle and permissions * Document preview DNS prefetch residual * Make preview E2E guest focus explicit * fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758) A grant whose document directory is its own request base — a doc at the workspace root, or outside any workspace — now reads nothing but the entry file until the reader approves a directory, at both the lexical and the canonical containment pass. The DNS-prefetch residual can only beacon what the page can read, and a root-level document could previously read the whole worktree silently. The identity chip's host badge overflowed the chip's layout box under squeeze (Linux CI): every row member can now shrink and truncate, verified by a width sweep in isolated Chromium down to ~120px chips. The Allow banner says what it grants: 'Allow folder', reading files in the named directory, for the life of the preview. The reliability-gate manifest command, testFiles entry, assertion refs and dated evidence naming the deleted doc-preview-external-link-bridge.test.ts are re-issued at doc-preview-external-link-confirmation.test.ts with a fresh 189/189 run; the focus-gate assertion text follows the shipped gate. * fix(browser-preview): hide the chip identity row below 24rem instead of clipping it, ellipsize the host badge, catalog the new i18n keys (STA-5758) CI's preview pane leaves the chip ~40px: no truncation shows anything there, so the Workspace-file label and host badge now hide whole below a 24rem container threshold sized so that visible implies contained. The badge text gains an inner text box — text directly inside the flex pill clipped both ends with no ellipsis. The e2e geometry oracle asserts containment when the row shows and the threshold when it does not. verify:localization-catalog: the hardening's new preview keys (and the renamed allowDirectory) join en.json via sync:localization-catalog. * feat(browser-preview): batch blocked folders into one access decision (STA-5758) Sequential per-folder banners trained the allow reflex without adding judgment — a reader cannot weigh assets/ against data/. The banner now accumulates every folder a load surfaces, names them (three, then a count, full list in the title), and grants exactly that set with one Allow-N-folders click and one reload. Dismiss fences the whole named set. The map lives behind a ref with a version tick so a dismissal fences an offer landing in the same event batch. |
||
|
|
b19a397d3e |
feat(browser-preview): reland remote HTML document previews (STA-5758) (#16920)
Reapply the reverted remote HTML document preview implementation so remote workspace files render locally over the orca-preview scheme. |
||
|
|
c72afda498 | wip: react185-cascade (#16940) | ||
|
|
c54bf92181 |
fix(window): stop will-navigate from blocking the lazy-chunk recovery reload (#16944)
* wip: lazy-chunk-reload * fix(window): reject non-document navigation schemes |
||
|
|
6f3c5cc039 |
fix(i18n): use 工作树 consistently for parent worktree strings in zh locale (#16954)
* feat: select parent worktree for nesting when creating workspace Enable users to pick a parent workspace in the composer's Advanced drawer, organizing newly created worktrees hierarchically in the sidebar. The backend validates lineage relationships and gracefully retries without the parent if it becomes unavailable during creation. * feat: select parent worktree for nesting when creating workspace - Record app-picked parents as manual actions, not CLI-flag equivalents, ensuring the same user action carries consistent cleanup semantics across hosts - Gracefully retry without parent if the selection goes stale, warn the user instead of failing - Refactor create into modules: parent resolution, payload building, state merge * Allow selecting parent worktree when creating workspace - New parent worktree picker filtered by execution host and project - Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership * Allow selecting parent worktree when creating workspace - Rename "Parent workspace" label to "Parent worktree" - Filter candidates by execution host and project to prevent nesting across hosts - Wire parentWorktreeId through composer state and creation request pipeline - Update translations and add new copy for nesting-related messages * fix(i18n): use 工作树 consistently for parent worktree strings in zh locale Addresses PR #15420 review feedback: parent worktree strings mixed 工作区 (workspace) and 工作树 (worktree) terminology. |
||
|
|
6f8c5888b3 |
Run Node 26 compatibility daily instead of per PR (#16946)
* Run Node 26 compatibility daily * Update relocated unit workflow contracts |
||
|
|
3d796b82cb | fix(claude-usage): bill 1-hour cache writes at 2x base input (#16878) | ||
|
|
52ade074a9 |
Display host on automation details and dialog (#16823)
* Show automation host in details and support moving between hosts - Rename AutomationCreateDestinationField to AutomationDestinationField to reflect dual use in create and edit modes - Add host display to automation detail view, showing storage authority - Allow editing automations to move them to different hosts within same authority; project list filters to available projects on chosen host - Update copy from create-only terminology to mode-agnostic wording * Display automation host and support cross-authority moves Users can now move automations to different storage authorities. The destination picker shows all available hosts, and selecting a new one displays a warning about the move. The save creates the automation on the destination and deletes it from the source; if deletion fails, both copies remain and the user is notified. * Remove cross-authority move support for automations An automation's authority (the Orca instance that stores and schedules it) cannot change; edits now only offer hosts within the same authority and move logic is removed entirely. This simplifies the destination picker and removes move-specific UI messaging. * Fix undefined selectedRowKey in automation host recovery Replace references to the undefined selectedRowKey variable with selectedRow?.key to properly access the row's key when recovering automation runs across hosts. * Support moving automations across execution authorities Allows users to move automations between different authorities (desktop ↔ runtime environments) during editing. A save to a different authority creates the automation on the destination and deletes the original with its run history. Includes clear messaging about the move operation, proper handling of workspace id resets, and graceful error handling when deletion fails. Supports destination-aware project and worktree fetching. * Reuse creationKey across move retries when schedule changes When retrying a failed automation move, dtstart is minted fresh each attempt, changing the payload. Previously, operationKey included the full payload, so retries would mint new creationKeys and risk duplicate automations on the destination if the initial create failed in transport. Now key only by the move (source + destination) to ensure stable creationKey across retries. Also fix workspace auto-selection to use authority-scoped worktrees instead of the merged cache, preventing unwanted restoration of source-host workspaces after switching authorities. * Rename `note` to `moveWarning` for automation host moves Clarifies that the field specifically warns when an automation would move to another host, replacing the plain storage line. |
||
|
|
06272109da | wip: remote-sidebar-offline (#16942) | ||
|
|
6b4537fa01 | wip: ssh-hidden-output (#16941) | ||
|
|
25fa6a9920 |
fix(ssh): a relay that reports a PTY absent must authorise a fresh spawn (#16763)
* fix(ssh): a relay that reports a PTY absent must authorise a fresh spawn reattachSshPtySession flattened the relay's `PTY "<id>" not found` into a bare `SSH_SESSION_EXPIRED`, discarding the evidence. isPtyAlreadyGoneError classifies on that message via /PTY ".+" not found/, so it could never match — and attachStablePaneOwner's existing already-gone fallback (retire the pane binding, onPtyExit, spawn fresh) is gated on exactly that predicate. The recovery machinery was already correct; only the classification was broken, which is why `pty:spawn` rejected outright after a relay restart renumbered its PTYs from pty-1. SshPtyAbsentFromRelayError carries the verdict the message cannot: a reachable relay answered for this exact id and reported it absent. Both thrown messages stay byte-identical to the previous ones, so all six message-based consumers are untouched; only callers that can act on the stronger verdict test the class. Deliberately not raised for a transport failure, timeout, disposed multiplexer, identity mismatch, or restoreRequired — none of those observe the process, and treating them as absence would orphan live remote work. * test(ssh): cover the absence fallback against real persistence The first cut passed no store/worktreeId, so attachStablePaneOwner's guard short-circuited and retireTerminalSurfaceFromPersistence — which deletes the parent tab and its layout when the retired leaf is the last one — never ran. retirePersistedStablePaneOwner and attachStablePaneOwner had no coverage at all. |
||
|
|
2b391652b1 |
fix(terminal): a close the host never heard must survive the reconnect (#16752)
An enterprise user: "Every day I open orca and it opens more tabs daily at a linear scale." Three reports over a week, told on 08-19 that a PR had fixed it, reported twice more after. STA-4658 (P0), GH #12447, #15136, #10342, #9585. One install held 39 zombie tab records. The revived tab's sleeping-agent record still holds the pre-close session id, so it boots `claude --resume <old id>` -- two agents on one transcript. ## The chain, measured Reproduced deterministically in `ssh-lost-kill-tab-resurrection.spec.ts`: close an SSH tab, kill the relay daemon in the container so `pty.kill` rejects with a transport-class error, reconnect. drop 2 resurrected the closed tab <id>: baseline=1 drop1=1 drop2=2 (closed tab returned) drop3=1 The trigger is narrow and had to be measured rather than assumed: killed relay daemon reproduces **6 of 6 runs**; an orderly `ssh.disconnect` **passes**. Only an ungraceful loss -- network partition, host reboot, relay crash, a laptop sleeping mid-session -- strands the close with the RPC rejecting on a transport-class error. Both variants live in the spec behind one `runResurrectionCycles` parameterized solely by the disruption, so the difference is attributable to that single variable. What actually carries the tab back, from the pull path (`workspace.get` -> `getRemoteSnapshot`, `remote-workspace-relay-sync.ts:29`): pullSnapshot rev=3 tabs={repo:["16c4a3e1","06aba6b6"]} pullSnapshot rev=4 tabs={repo:["16c4a3e1","ff72768e"]} <- ff72768e IS the resurrected tab pullSnapshot rev=5 tabs={repo:["16c4a3e1","ff72768e","da21b76c"]} The client uploaded the session containing the tab; the user closed it; the kill RPC rejected so the close never reached the host; the host's snapshot still lists it; the client pulls it back and the merge restores it -- **correctly, by its own rule that the host is authoritative for what it knows.** A pane then mounts, respawns, and takes the recycled pty id. Client-side correlation from the same run, two controls and one positive in one run differing in exactly one variable: | Tab | Close events observed | Resurrected? | |---|---|---| | `6305cc07` | `user` + `pty-exit` | No | | `ed56f66c` | `user` + `pty-exit` | No | | `2036e760` | `user` only | **YES** | ## The fix `src/shared/closed-terminal-tab-tombstones.ts` (99 lines). A client-recorded close is first-party intent and must survive until the host acknowledges it. Per `docs/reference/ssh-execution-boundary.md` the remote verdict is `unverifiable` -- which may not authorise declaring the process dead, but equally must not authorise resurrecting the tab. This is SSH-v3 principle P2, "durable tombstones with a monotonic per-scope revision", reusing the existing `RemoteWorkspaceSnapshot.revision` rather than adding a twelfth per-tab identity field (the codebase carries eleven, 784 refs, that SSH-v3 Phase 3 deletes). - **Recorded** only on `closeReason === 'user'` (`terminal-tab-close.ts:69`). - **Suppresses** a host-sourced tab only when `tabId in tombstones && !currentTabsById.has(tabId)` -- a live local tab always wins, because deleting a live pane is the one outcome the merge exists to avoid. - **Retires** on positive acknowledgement: `!hostKnownTabIds.has(tabId) && hostRevision > observed`. Strictly newer, so a pull already in flight at close time cannot ack a close it predates. - Three never-retire guards: no revision retires nothing; a worktree the snapshot has no row for retires nothing; the first omitting snapshot only stamps the watermark. - TTL (30d) + cap (500) are **backstops** for a target the user never returns to, not the mechanism. - **Client-local only** -- never crosses the wire, so there is no mixed-version exposure. - Suppression is scoped to `replaceWorktreeIds`, which is what makes the live-tab check meaningful. A final whole-map sweep over the assembled `tabsByWorktree` would break that (a live tab is absent from `currentTabsById` outside the scope and would look suppressible); it is deliberately not there, and the comment at the top of the function says so. ## Evidence The load-bearing evidence is an A/B control on one tree, not the oracle's assertion. Flipping `isSuppressedByClose` to `false` -- one character -- reproduces the resurrection on demand: --repeat-each=2: 1) drop 2 resurrected the closed tab ab0e305d-…: baseline=1 drop1=1 drop2=2 2) drop 2 resurrected the closed tab 51533e34-…: baseline=1 drop1=1 drop2=2 2 failed With suppression on: **0 occurrences of "resurrected the closed tab" across five runs plus one independent run by a second agent.** Provenance verified positively, not by mtime: `closedTerminalTabTombstonesByTabId` appears 13x across 3 renderer chunks including `store-Do3KBvRE.js`; for every red control run `mayCreate` appeared 0 times in `out/main/index.js`. At the unit layer, disabling the same predicate: 3 failed | 39 passed. Restored: 42 passed; 287 across the workspace-session, terminal-store, remote-workspace, shared-tombstone and profile suites; 24 in the four tombstone suites. ## The oracle spec: GREEN in the full lane `ssh-lost-kill-tab-resurrection.spec.ts` passes both tests at this commit. Full Docker-SSH lane, clean tree: BUILD_SHA=49bb96e0b4c DIRTY=0 PROVENANCE tombstone=13 hasLocalTabsRow=2 hostAuthority=4 mayCreate=3 14 specs / 20 tests -> 17 passed, 2 failed, 1 skipped (10.7m) [12/20] :178 does not resurrect tabs whose kill was lost to a killed relay daemon PASSED [13/20] :190 does not resurrect tabs closed while the host is disconnected PASSED grep -c "resurrected the closed tab" (whole lane) -> 0 It passes WITHOUT PR 7 in the build (`mayCreate` present, `SshPtyAbsentFromRelayError` absent), so the bug-2 fix below is not required for it. Test 1 fails intermittently in ISOLATED single-spec runs, where a third defect blocks its cycle-2 setup. The resurrection assertion itself has never failed with this fix in place -- the intermittent failure is always a setup failure, never a resurrected tab. A reviewer running the spec alone may see it red; that is not this fix regressing. Three defects sit under STA-3374 and should not be conflated: - Bug 1 -- the closed tab resurrects. Fixed here. - Bug 2 -- `ssh-pty-session-reattach.ts:227-231` rewrites the relay's `PTY "pty-1" not found` into a bare `SSH_SESSION_EXPIRED`, so `isPtyAlreadyGoneError`'s `/PTY ".+" not found/` cannot match and `attachStablePaneOwner:242`'s already-correct fallback never runs. Owned by PR 7 (`nwparker/ssh-07-absent-from-relay`). Not required for the oracle above. - Bug 3 -- after the daemon is killed and the client launches a replacement, the client's OWN SSH transport drops and does not reconnect within 60s: no "delay step 2/9", no handshake failure, nothing. `ssh-connection.ts:1533` only logs on an SSH-level close. Unfixed, its own ticket. This is what makes test 1 intermittent in isolation. Discriminator for bug 3, measured in the isolated runs (the lane above ran without `ORCA_E2E_FORWARD_APP_LOGS=1`, so it was not re-confirmed there): `[ssh-relay] Socket probe result:` reads "DEAD" on every cycle of test 1 (daemon killed, a NEW relay must be launched) and "ALIVE" on every cycle of test 2 (daemon survived). Whenever a new daemon must be launched, the SSH transport drops afterwards and does not recover. An earlier reading blamed `kill.ts:82-84` for skipping `finishPtyShutdown` on a non-already-gone error. That was eliminated by direct test: the implied fix, `markSshRemotePtyLease(…, 'expired')` in that branch, was implemented, changed nothing, and was reverted rather than shipped unproven. Recorded so the path is not re-walked. The `SSH_SESSION_EXPIRED` rejection is real but fires during cycle 1 for the baseline pane, after which cycle 1 completes; the 60s silence begins only after `Relay channel lost ..., triggering reconnect`. The spec is claimed by the Docker-SSH lane, and that lane does not gate merges today. ## Persistence: the tombstone must survive a relaunch `closedTerminalTabTombstonesByTabId` is declared on `WorkspaceSessionState` but was missing from `workspaceSessionStateSchema` (`src/shared/workspace-session-schema.ts`), which is the load boundary for BOTH partitions -- `normalize-loaded-state-collections.ts` for `local` and `workspace-session-partitions.ts` for `ssh:<target>`. Zod strips unknown keys and the write side does not validate, so the map reached disk and was discarded on the next launch. Measured with the repo's own parser: input : closedTerminalTabTombstonesByTabId: { 'tab-1': {...} } ok = true tombstones after parse = undefined That made the fix ineffective in the exact reported scenario: close an SSH tab with the transport down, QUIT, relaunch, reconnect -- the merge runs with an empty map, the host still lists the tab, and it resurrects. "Every day I open orca and it opens more tabs" is a claim about restarts. Neither the green oracle nor the A/B control could see it: both run entirely inside one app process. It also made the 30-day TTL and the 500 cap unreachable. Fixed by adding the field with a `salvagingRecord` matching its sibling `terminalSurfaceTombstonesByPaneKey`, so one malformed entry drops that entry rather than the map. `workspace-session-schema.ts` was one line under its 300-line max-lines limit, so adding the field required room rather than a suppression (the project forbids max-lines disables and per-file bumps). Two value schemas were extracted to modules named after what they contain: `terminal-tab-id-schema.ts` and `terminal-surface-tombstone-schema.ts`. The closed-tab tombstone's own schema is colocated with its type in `closed-terminal-tab-tombstones.ts`, which is where it belongs -- omitting it from the session schema is exactly the drift that caused this bug. `workspace-session-schema-field-coverage.test.ts` is the ratchet. Two sibling tables already pin themselves with `satisfies Record<keyof WorkspaceSessionState, ...>`; this schema had no such guard and is the one that fell behind. The new file adds both halves -- a `satisfies` list that makes a forgotten field a compile error, and a runtime assertion that names it -- plus a `parseWorkspaceSession` round-trip. Without the schema entry: 3 failed. With it: 3 passed. ## A host tab the user never closed could be deleted `tabId in closedTerminalTabTombstonesByTabId` answers true for every `Object.prototype` key even on an EMPTY map, because the map is a plain object from `Object.fromEntries`. A host tab whose id is `toString` was filtered from the reconciled list, blocked from the host-unknown branch, and stripped of its layout and session id. Tab ids are validated only as non-empty and colon-free, and `createTab` honours caller-supplied id hints, so the id is reachable rather than theoretical. This was the only path in either direction that could delete a tab the user never closed. Now `Object.hasOwn`, as the same file already uses elsewhere. Suppression is also scoped structurally: `isSuppressedByClose` compares the tombstone's stored `worktreeId`, which it already carried, so it cannot reach another workspace's tab. The two sweeps that have no worktree in scope (`terminalLayoutsByTabId`, `remoteSessionIdsByTabId`) now consult the set of ids this merge actually suppressed rather than re-deriving a verdict without that scope. The scope comment at the top of the function was also wrong and is corrected. It claimed every use of suppression sits inside `replaceWorktreeIds`; it does not -- the tabs pass walks all of `orderedWorktreeIds` and the two sweeps cover the whole remote maps. What actually makes it safe is that `closeTab` strips the id from every worktree row before recording the tombstone, plus the worktree match above, plus `closeReason === 'user'` being the only writer. Real guarantee, different from the documented one. ## Divergences from open PR #16571 #16571 implements the same concept. Three deliberate changes: 1. It never retires on acknowledgement -- TTL+cap only, so it never converges. Ack retirement added. 2. It crosses the wire and lets a HOST-sourced tombstone delete a LOCAL tab in a final whole-map sweep. After #14361 that is the wrong risk; dropped. This also removes the mixed-version regression its own body flags. 3. Its hydration unions rather than replaces the map -- a union resurrects every tombstone the merge just retired, so it never converges. Its `activeTabId` nulling is also dropped as redundant: `workspace-terminal-hydration.ts:99-105,126-138` already revalidates both pointers against the tab rows it just built, and nulling twice would add a second rule that has to stay in step with the first. ## Can a tab the user did NOT close disappear? No, but the guarantee needs stating precisely. The only writer is `recordClosedTerminalTabTombstone` (`terminal-tab-close.ts:69`), reachable only on `closeReason === 'user'`; suppression additionally requires the tab not be live locally. Reopen (`recently-closed-tabs.ts:122-166`) calls `createTab` and restores cwd/shell/title/color/position, never the old id. **Caveat, stated because the slogan is not literally true:** `createTab` honours a caller-supplied id hint (`terminal-tab-creation.ts:53-65`, used by `useIpcEvents` for host-admitted tabs), so "tab ids are uuids that never recur" does not hold in this codebase. The guarantee rests on the `closeReason === 'user'` writer plus the live-local-tab check, not on id uniqueness. ## Risk Renderer-side, client-local, no wire change. The blast radius is `mergeDirectSshRemoteWorkspaceSession` and the persisted session field. Worst case if the ack logic were wrong in the retiring direction: a tombstone outlives its usefulness and suppresses a host tab whose id the host re-issues -- bounded by the live-local-tab check, the 30d TTL and the 500 cap. Worst case in the other direction is today's behaviour. `profile-project-session-field-disposition.ts` records the new field as `notRepoScoped` / `notTransferred` residue, bounded by the same TTL and cap. ## Verify pnpm test src/shared/closed-terminal-tab-tombstones.test.ts \ src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts \ src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts \ src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts To reproduce the bug this fixes, set `isSuppressedByClose` to `() => false` in `remote-workspace-session-merge.ts` and run `pnpm test:e2e:ssh-docker -- tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts --repeat-each=2`. |
||
|
|
a7b1da9e14 |
fix(ssh): a reattach may bind a pane but must never create one (#16751)
`reattachKnownPtys` treats every non-terminated lease as live and calls `persistPtyBinding`, which had no way to say "bind only". Two of its branches then rebuild UI the user is not asking for: - `pty-binding-persistence.ts:133-143` -- `if (args.incarnationId)` unconditionally deletes the pane's close tombstone. - `:145-160` -- on a tab-not-found it mints one via `createMinimalPersistedTerminalTab`. The in-code comment names its only intended caller: "pty:spawn can beat the debounced writer." Spawn. Reattach took the same branch. This is the mechanism canceled ticket STA-4268 described: "Leases have no pane incarnation and upsert only by target/PTY. Every nonterminal lease is reattached; frozen coordinates are passed to persistPtyBinding, which creates and flushes missing tabs and layout leaves." It was fixed in #13326, reverted by #14361, re-applied by #14384, and reverted again by #14395 (opened and merged nine seconds apart, empty commit body). `grep -rn "mayCreate" src/` returns nothing on main -- the mechanism is genuinely out of the tree. Four parts: 1. `mayCreate` (default true). When false, one pre-mutation check mirrors all four creating branches and returns `false` without mutating, so a refusal leaves nothing half-written. 2. The authority gate -- the part both prior attempts lacked, and probably why both were reverted. `mayCreate: false` alone refuses in two situations: "the user closed it" AND "the renderer has not published its layout yet". The second is routine on disconnect->reconnect and is almost certainly the #14361 tab-loss mechanism. The fence was not wrong; it was UNCONDITIONED. It is now passed only when `hasHostAuthoritativeTerminalMembership()` says the persisted membership speaks for this worktree, reusing the function already guarding the same question at `orca-runtime.ts:8608`. Losing a tab is worse than keeping a duplicate, so an unauthoritative session still gets the creating write. Authority is read from `local` because that is the partition the write lands in -- it is local's absence being interpreted. But a pane the `ssh:<target>` partition still holds is not gone, so it keeps its creating write; refusing there would strand a live pane behind a binding reattach can no longer reach. (SSH spawns bind into `ssh:<target>` while this reattach binds into `local` -- GH #12721/#12723, STA-3980. This does not fix that split; it refuses to judge from one side of it.) 3. `findTerminalTabIdForLeaf` -- bind resolves the tab from the live layout instead of the lease's frozen `tabId`. Only the leaf half of a pane key is remint-stable: `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the pane left. Identity vs location. 4. Pane-keyed supersession -- retires siblings on `(targetId, worktreeId, leafId)` to `expired`, guarded by the durable binding, which reads `local` then `ssh:<target>` so it is correct whichever partition the binding landed in. `upsertSshRemotePtyLease` matched `(targetId, ptyId)` alone (`ssh-pty-lease-operations.ts:34-36`), so a new relay pty id on reattach minted a SECOND lease instead of updating the first, leaving the predecessor non-terminated with nothing to retire it. On refusal the lease goes `expired`, never `terminated` -- `expired` records that this shell has no surface to reach it through; `terminated` would assert an exit nothing here observed, which `docs/reference/ssh-execution-boundary.md` forbids. The remote process is left running. Deliberately NOT done: - A collision guard for `upsertSshRemotePtyLease`. Built, tested, and REMOVED -- its own test passed with the guard disabled, i.e. vacuous. Telling "same lease" from "recycled id on a different shell" needs a relay-start identity, which would be a twelfth per-tab identity concept; the codebase already carries eleven (784 refs) that SSH-v3 Phase 3 deletes. Left as an in-code NOTE. This handles lease DIVERGENCE, not COLLISION. - A port of #13324. Its own authors deleted its load fold and reverted its local-only reader in #13326 ("a headless-owned pane still gets a vote before its lease is retired"); porting it ships a state-destroying migration they removed. - `bindPaneShell` from #13325. Its purpose is making `isSupersededPtyId` live, and that fence does not exist in main. It would have been a refactor plus a silently-ignored `mayCreate` -- TS drops excess props through spreads (verified), which is why `mayCreate` is passed as a conditional spread here. Honesty about scope: this does NOT close the daily-tab-growth report. The deterministic e2e repro (`ssh-lost-kill-tab-resurrection.spec.ts`, later in this stack) is byte-identical before and after, and instrumentation shows why -- in that scenario `restoreReattachedPtyRuntime` is never called at all (`CREATING TAB` 9 hits, `reattach gate` 0, `BYPASS` 0). The fence is on a path that bug does not take. It is a real, separately-provable defect; it is not the headline fix, and must not be claimed as one. Evidence, A/B on this tree. Disabling the authority gate (`mayCreate = true`) and the supersession call by hand: **8 of 12 fail**, including "does not resurrect a tab whose closing pty.kill failed with a transport error" and "holds the live lease count flat across ten reconnects of one pane" (`[ 'pty-0', 'pty-1', 'pty-2', …(7) ]` vs `[ 'pty-9' ]`). The 4 that pass both ways are the over-refusal tripwires, which is the point of having them. Restored: 12 passed; 2,439 passed across `src/main/ssh`, `src/main/persistence` and `src/main/runtime/workspace-session`. |
||
|
|
e06a8667a9 |
fix(terminal): do not seed or resume while the execution host has not answered (#16750)
Two client behaviours read local tab rows as the verdict on what the execution host is running. Before the host answers, "I hold no pane for this" is `unverifiable`, not `exited` -- the collapse `docs/reference/ssh-execution-boundary.md` forbids. Symptom 1, seeding. `worktree-initial-terminal-seeding.ts:47,128` seeds a terminal when `renderableTabCount === 0`. Its only bail-out (`:72-77`) covered the paired-web-runtime flavor -- "while that session is live the host owns terminal creation" -- with no equivalent for direct SSH. So a client that has never held the workspace runs the predicate during the hydration gap and creates a tab from nothing. The snapshot then arrives, the merge rightly keeps the tab it was never told about, and the union uploads as the new host truth. Measured on a fresh client against a host owning 3 tabs: **1 tab created from nothing, 0 of the host's 3 adopted.** (A restart never reaches the predicate -- local state restores the row first -- which is why restart-only repros came back flat.) That guard was also the wrong question. It asked "am I a client of a live paired session?", which a host desktop window answers "no" and a paired client answers "yes", so both seeded -- #15556. Symptom 2, sleeping-agent resume, and the data-corrupting half. `Terminal.tsx:1554` calls `resumeSleepingAgentSessionsForWorktree` twenty lines after the seeding call at `:1529-1534` -- same startup path, same pre-hydration window, and not SSH-gated at all. Seeding produces a spare empty tab; the sweep launches `claude --resume <id>` for a session still running on the remote and still owned by a live pane. Two agent processes writing one transcript; STA-3498 observed five. STA-3500 files exactly this race. Failure is asymmetric: declining to resume is user-recoverable, a duplicate resume corrupts a transcript irreversibly. `workspace-terminal-host-authority.ts` answers the one ownership question both paths ask, in the three-verdict vocabulary the renderer already uses for host terminal inventory (`HostLiveTerminalProbeVerdict`, aliased rather than restated so the two cannot drift): `live` (a remote host owns creation here), `unverifiable` (there is a remote host and it has not answered), `none` (local, or the host answered and holds nothing). Seeding requires `none`; the sweep declines on `unverifiable` without consuming its one-shot, so the agents are not stranded for the session once the verdict lands. Shape notes: - An ownership question, not a client-liveness one -- that is what fixes #15556. - Folder workspaces resolve to `none`: the snapshot replaces exactly `DirectSshTargetScope.gitWorktreeIds`, so a folder's rows are never replaced by the host and waiting for an answer that will never name them would leave it terminal-less for good. - A `conflict` sync phase is `unverifiable`, matching the pair `use-app-session-persistence.ts` already gates uploads on. - Explicit launch work (setup/issue commands) stays ungated -- that is a request to create a terminal now. - `Terminal.tsx` subscribes through a retained selector rather than reading in the effect: the verdict flipping to `none` is what must re-run the passes, and resolution walks the owner catalogs, so recomputing per store write would be the STA-3363 render-path multiplier again. The `unverifiable` verdict is BOUNDED, and must be. `remoteWorkspaceHydratedTargetIds` is add-only in practice -- `markRemoteWorkspaceHydrated` has two production call sites, both on success paths, and `clearRemoteWorkspaceHydrated` has NONE. Four paths return without marking: local-hydration timeout (`remote-workspace-target-sync.ts:136-145`), a null `remoteWorkspace.get` (`:160-169`), a falsy apply token (`:172-185`), and never connecting at all. Without a floor, any of them would leave every git worktree on that target `unverifiable` for the rest of the app session: no initial terminal, no sleeping-agent resume, escapable only by creating a tab by hand. That is strictly worse than the behaviour it replaces -- on main the user got a terminal. So a sync that terminates in `offline` or `error` without ever hydrating resolves `none`: declining to seed is meant to be a wait, not a permanent refusal. `pulling` still declines, and a target that HAS hydrated stays `none` even if a later sync errors. Scope, stated because the doc comment previously overstated it: this gate is first-hydration-per-target, not per-connection-generation. Since nothing clears the flag, a disconnected target that hydrated once reads `none`. It does not cover mid-session reconnect or sleep/resume. The memo's input list is checked for COMPLETENESS, not just membership. `satisfies readonly (keyof State)[]` only proves each listed key exists; a field added to the state and forgotten from the list would type-check while making the memo return a stale verdict -- silent, and it looks like "the gate did not fire". A conditional type now names the missing key at compile time. Deliberately not `const x: Missing[] = []`, which passes regardless because an empty array literal is assignable to every array type. Known limitation, stated rather than hidden: the SEEDING half of this change has no measurable end-to-end effect today, and the branch's own e2e spec says so. `applyDirectSshRemoteWorkspaceSnapshot` calls `markRemoteWorkspaceHydrated` unconditionally AFTER the hydrate calls -- including when they wrote nothing. So in the same tick adoption yields zero, the verdict flips `unverifiable` -> `none`, `Terminal.tsx` re-runs the effect, and it seeds. The gate cannot outlive the failure it guards against, because the same function that fails to adopt is the one that lifts it. `ssh-cold-hydration-gap-tab-seeding.spec.ts:218` is named for what it asserts -- one tab, adopted none -- rather than for the behaviour we want. The fixme at `:293` pins the intended behaviour. Making the seeding half effective needs hydration resolved PER WORKTREE (or a refusal to say `none` when the completed apply's `replaceWorkspaceKeys` did not name this worktree) rather than a per-target "some apply finished" flag. That is deliberately not in this commit. The RESUME half is the valuable half and is unit-proven: it declines while the host is unanswered and wakes the same session once the verdict lands, without consuming its one-shot. Preventing one duplicate `claude --resume` on a live transcript is worth more than preventing one spare tab -- declining to resume is user-recoverable, a duplicate resume corrupts a transcript irreversibly. Before: 7 failed | 3 passed. After: 10 passed; 103 across the seeding, resume, authority and remote-workspace suites. |
||
|
|
8b99c8365d |
fix(remote-workspace): host silence must not delete a local row it was never told about (#16748)
Two fields in `mergeDirectSshRemoteWorkspaceSession` treated an absent remote entry as an authoritative delete. Both are records whose *absence* is meaningful, so deleting them on silence loses information the host never had. 1. The closed-last-terminal tombstone. `src/renderer/src/components/terminal/initial-terminal.ts:5` states the contract verbatim: "a missing row means never initialized; an explicit empty row records that the user closed the last terminal." `mergedWorktreeIds` was `keys(remote.tabsByWorktree)` union the replaced worktrees whose local tab list is NON-EMPTY (`:41-45`), so a worktree holding an explicit `[]` and absent from the host snapshot was excluded, `omitTargetWorktrees` stripped the key, and nothing re-added it. Measured before the fix: `Object.hasOwn(merged.tabsByWorktree, WORKTREE)` => false. Downstream, `worktree-initial-terminal-seeding.ts:125` computes `shouldHonourClosedTerminalTombstone = Object.hasOwn(store.tabsByWorktree, id) && ...` => false, so `shouldAutoCreateInitialTerminal(0, false)` => true and a terminal is created. The user closes their last terminal on an SSH workspace and it comes back on reconnect. The projection is innocent: `remote-workspace-session-projection.ts:47-59` and `:161-168` both round-trip an empty array faithfully. The row is lost only when the host snapshot has no entry for that worktree path at all -- a first sync, a snapshot predating the close, or `resolveWorktreeId(path)` returning null during startup. Fix: admit a replaced worktree whose local row EXISTS (`Object.hasOwn`) rather than whose local row is non-empty. That is the same "the host is authoritative for what it knows, not for what it has never been told" rule the rest of this function already applies to tabs. 2. `defaultTerminalTabsAppliedByWorktreeId`. This was the only field in the function with no preservation branch: `:257-260` deleted the local entry for every replaced worktree and trusted the remote snapshot to carry it. The marker is write-once and is the sole guard on `applyDefaultTerminalTabs` (`worktree-default-terminal-tabs.ts:34`), so deleting it re-applies the whole default-tab template over the user's tabs. Removal belongs to the worktree-teardown path, not to a reconnect. Why this shape rather than a revision counter: both are statements about what absence means, local to one function, so they survive the SSH-v3 consolidation unchanged. Neither adds a per-tab identity field. Deliberately not done: the `hostUnknown` preserve branch is untouched, and no close-suppression is added here -- that is the durable close tombstone, and it belongs on top of this rather than mixed into it. Before: 3 failed | 18 passed. After: 21 passed (45 across the wider merge, default-tabs and initial-terminal suites). |
||
|
|
f864223723 |
fix(remote-workspace): carry defaultTerminalTabsAppliedByWorktreePath through the normalizer (#16747)
`defaultTerminalTabsAppliedByWorktreePath` is declared on the wire session
(`src/shared/remote-workspace-types.ts:16`), emitted by the renderer's export
(`remote-workspace-session-projection.ts:101-134`) and read back by its importer
(`:195`) -- but `normalizeRemoteSession` rebuilds the session as an object
literal and never copied it. Measured on a snapshot carrying `{ '/r': true }`:
normalizeSnapshot(...).session.defaultTerminalTabsAppliedByWorktreePath
=> undefined
remoteWorkspaceSessionMatchesSnapshot(withFlag, withoutFlag)
=> true
Two consequences. `getRemoteSnapshot` normalizes everything the relay returns
(`remote-workspace-relay-sync.ts:29`), so a client could never receive the flag
even though the write path sends `session` verbatim. And
`patchRemoteWorkspaceSession` short-circuits on
`remoteWorkspaceSessionMatchesSnapshot` (`:51`), which normalizes both sides --
so a change that only sets this marker was invisible to the dirty check and
could never be written at all.
The marker is the sole guard on `applyDefaultTerminalTabs`
(`worktree-default-terminal-tabs.ts:34`), so losing it re-applies the whole
default-tab template over the user's tabs.
This has never worked in either direction since the field was introduced.
`normalizeRemoteSession` predates `372469b7528` under a different path -- it
lived in `src/main/ipc/remote-workspace.ts` until `15e1ba3f84a` split it out for
max-lines, and at `372469b7528` it already copied all four sibling optional
fields and simply never gained a fifth. No test caught it because that commit
tested the projection round-trip (correct in isolation) while the normalizer sat
in another file with no test of its own.
The new test file closes that gap two ways: a case per consequence, plus a
`Required<RemoteWorkspaceSession>` fixture asserted key-for-key against the
normalizer's output. Because the normalizer is an object literal, the next field
added to the wire type and forgotten there is now a compile error in this test
rather than silent data loss.
Wire compatibility: safe, but not by the plain "new optional field" rule -- this
is an already-declared field that was silently stripped on every read. Both
readers treat it as optional; the relay is not a reader at all
(`src/relay/workspace-session-handler.ts:145-151` stores `patch.session` as an
opaque record and returns it verbatim), so no relay version can strip it. A
half-upgraded pair is one-directional: an old client still drops the key on read
and behaves exactly as today.
Before: 3 failed | 1 passed. After: 4 passed.
|
||
|
|
971d987c4b |
ci(e2e): trigger the Docker-SSH lane from SSH source and claim every gated spec (#16746)
The Docker-SSH e2e lane only ran when a PR's changed specs happened to include
`ssh-startup-exec-readiness.spec.ts` or `paired-startup-exec-readiness.spec.ts`.
Editing SSH source itself did not trigger it, and pruning either spec from a
route's list would have silently retired the whole lane. Meanwhile the sharded
lanes set no `ORCA_E2E_SSH_DOCKER`, so every Docker-gated spec skipped itself
while the shard still reported green -- the exact silent-skip shape
`docs/reference/ssh-reconnect-source-recovery.md` blames for four regressions
that reached users.
Separately, the modules that actually own direct-SSH workspace and tab restore
carry no "ssh" in their names, so the `ssh-terminal-source` route never reached
them. Measured on the real script before this change:
printf '%s\n' src/renderer/src/hooks/remote-workspace-session-merge.ts \
src/main/ipc/remote-workspace-snapshot-normalization.ts \
src/renderer/src/lib/worktree-initial-terminal-seeding.ts \
src/shared/remote-workspace-session-projection.ts \
| node config/scripts/pr-e2e-source-routing.mjs
=> []
Three changes, all pinned by the executable gate contract:
- `hasSshSourceChange` derives an `ssh_source_changed` signal from the SSH
routes themselves, plumbed pr.yml -> e2e.yml, so the lane triggers on source
rather than on a spec name surviving in a list. One list, so the two cannot
drift.
- A sibling `ssh-workspace-session-restore` route names the restore seams
(`remote-workspace-*`, `worktree-initial-terminal-seeding`,
`worktree-default-terminal-tabs`, `initial-terminal`) and routes them to the
two restore specs -- a sibling rather than more paths on `ssh-terminal-source`
so a tab-tombstone edit does not run the whole SSH terminal list.
- A new `test:e2e:ssh-docker` runner claims the remaining Docker-gated specs on
the one VM that sets the flag, and the contract now fails by name when any
Docker-gated spec is claimed by no runner. `ssh-docker-relay-perf` and
`ssh-codex-display-artifacts-repro` are recorded exemptions (wall-clock
budgets; needs a real remote codex binary) and the contract asserts each
exemption still corresponds to a real gated spec, so a stale one cannot
quietly excuse a gap. Lane timeout raised 35 -> 60 minutes for the added
serial specs.
The lane's first act was to surface four latent bugs in a spec that had been
silently skipping. `ssh-docker-bulk-open-freeze-repro.spec.ts` is four call sites
out of date against `tests/e2e/helpers/terminal.ts`: `startDockerSshRelayTarget()`
is called with no argument though the helper dereferences `testInfo.workerIndex`
(a 100% failure, not a flake), `execInTerminal` gained a `ptyId` parameter, and
`splitActiveTerminalPane` gained a direction. It was invisible because it ran
nowhere and `typecheck:e2e` is red on main with 240 pre-existing errors, so four
more could not be seen.
The `testInfo` bug is fixed here -- correct on its own, and it removes one real
error from `typecheck:e2e` (240 -> 239). The other three are not, because they
are not argument plumbing: repairing them requires choosing which ptyId to
capture and which split direction to use, and both change what the repro
measures.
The spec is therefore added to the exemption list rather than repaired, for two
independent reasons recorded in the runner: it is a perf oracle, not a
correctness one (`SOFT_FREEZE_LAG_MS=2500` / `HARD_FREEZE_LAG_MS=5000` measured
under a deliberate 5-pane flood on a 420s budget -- the same rule already applied
to `ssh-docker-relay-perf.spec.ts`), and it is known-rotted. Repair is tracked in
stablyai/orca#16764. Applying an existing written rule to a sibling that plainly
meets it is consistency; inventing a new exemption to dodge a red would not be.
Three hardening fixes to the contract itself:
- Runner text is comment-stripped before the claimed-by-a-lane scan. A substring
scan over raw text lets a spec merely *discussed* in a runner comment count as
claimed -- the silent skip this assertion exists to catch, re-entering through
the documentation. Not live today only because the existing comments write the
spec names without their `tests/e2e/` prefix.
- An exempt spec must not be invoked by any runner. `unreachableSpecs`
short-circuits the unclaimed check, so a spec could be documented as exempt
while a runner still ran it -- an exemption that reads as coverage removal but
changes nothing, leaving the lane red for a reason the file says it excluded.
This is not hypothetical: adding the bulk-open exemption without removing it
from the runner's spec list produced exactly that state, and this assertion is
what caught it.
- The Docker-gate detector is now `/ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/`
rather than one fixed string, so a double-quoted or `!==` spelling can no
longer escape the contract.
`ssh-restart-tab-accumulation.spec.ts` is a new three-cycle restart fence
asserting tab-id set identity, not just the active pane's reclaimed ptyId as
`ssh-cold-activation-restore.spec.ts:241` did. It passes today; it was validated
by a negative control that injected one tab after cycle 1 and correctly failed.
|
||
|
|
2ac23c29be |
Add parent worktree selection when creating workspace (#15420)
* feat: select parent worktree for nesting when creating workspace Enable users to pick a parent workspace in the composer's Advanced drawer, organizing newly created worktrees hierarchically in the sidebar. The backend validates lineage relationships and gracefully retries without the parent if it becomes unavailable during creation. * feat: select parent worktree for nesting when creating workspace - Record app-picked parents as manual actions, not CLI-flag equivalents, ensuring the same user action carries consistent cleanup semantics across hosts - Gracefully retry without parent if the selection goes stale, warn the user instead of failing - Refactor create into modules: parent resolution, payload building, state merge * Allow selecting parent worktree when creating workspace - New parent worktree picker filtered by execution host and project - Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership * Allow selecting parent worktree when creating workspace - Rename "Parent workspace" label to "Parent worktree" - Filter candidates by execution host and project to prevent nesting across hosts - Wire parentWorktreeId through composer state and creation request pipeline - Update translations and add new copy for nesting-related messages |
||
|
|
4f14d6d757 |
fix(mobile): stop held accessory keys after release under latency (#14219)
* fix(mobile): backpressure accessory key repeats * fix(mobile): keep accessory repeats on the pressed terminal * fix(mobile): serialize accessory key presses * fix(mobile): preserve queued accessory taps * fix(mobile): fence queued taps across reconnects * fix(mobile): recheck queued tap delivery context * fix(mobile): stop accessory repeats after IME send failure * fix(mobile): pace repeated live-input edits * fix(mobile): dispatch accessory taps without ack delay |
||
|
|
3b4a15e609 |
test(editor): add copy button tests for check run details (#16925)
Add tests verifying that copy buttons render and correctly copy content to clipboard for the output, annotations, and jobs sections of the check run details panel. |
||
|
|
ebe6b559ef | Prime changed native caches before test fanout (#16918) | ||
|
|
f96dfd5810 | Extract CDP auxiliary commands (#16781) | ||
|
|
3ed7796624 |
fix(fork-sync): address runtime repos by main worktree id, not repo id (#16876)
Repo-level fork sync (Safe Auto and the Sync Now button) passed `repo.id` as the runtime worktree selector, so runtime-hosted repos always failed with `worktree_id_requires_full_path`. Compose the repo's main worktree id (`<repoId>::<repo.path>`) via a new shared `getRepoMainWorktreeId`. Fixes #16447 |
||
|
|
c90d9c4780 | Extract GitLab dialog presentation parts (#16780) | ||
|
|
bdabb09a07 | Extract mobile host and browser helpers (#16779) | ||
|
|
f48c038a93 | Extract local PTY launch helpers (#16778) | ||
|
|
e16f3943fd | Extract daemon launch path helpers (#16777) | ||
|
|
39ac32f279 | Extract terminal output scheduler subsystems (#16775) | ||
|
|
86bedd1550 | Split onboarding flow controller (#16774) | ||
|
|
b63c006ae9 | Update Android download links to 0.0.46 (#16899) | ||
|
|
2aec442f42 | Split Linear issue service by operation (#16770) |