mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
6c8eea5ebeac6948998f769aa0ec4fa8a5ec72ab
3718
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6c8eea5ebe | perf(worktree): fix the prepared-checkout hit rate and make misses visible (#17863) | ||
|
|
7a69357856 | fix(worktree): widen git-common watch on event-batch overflow (#17916) | ||
|
|
a7db6c336b |
perf(git): skip the sparse probe for worktree listings that never read it (#18050)
Three main-process call sites list a repo's worktrees to read `worktree.path` and nothing else, but went through the annotated listing, so each one paid a sparse-checkout probe per worktree and cached the result nobody consumed: - `registered-worktree-roots-cache.ts` rebuilds the filesystem-auth authorized roots. `invalidateAuthorizedRootsCache()` fires on every worktree create and remove, plus repo add/clone/settings changes, so this reruns constantly. - `filesystem-source-control-ai-targets.ts` checks whether a local repo owns a worktree path. - `hosted-review.ts` verifies a worktree belongs to the repo before granting access. The probe is an `fs.stat` of the per-worktree `info/sparse-checkout` plus, when that file is non-empty, a git config read. On a WSL-hosted repo both cross 9p. #17859 cached it and #17932 keyed that cache on the distro, which fixed a wrong answer but also meant the distro-less callers above populate a second entry per worktree — probed cold, revalidated on their own five-minute loop, and read by nobody. Worktree create/remove clears the sparse cache and dirties the roots cache together, so both variants go cold at once and the discarded half is re-probed in full on the next auth check. `listRepoWorktreeGraph` routes those callers to `listWorktreeGraph`, which already existed as the annotation-free listing (#17655). Doing only that would have cost a second `git worktree list`. The scan cache keys in-flight scans on a `kind`, and graph and lenient were separate kinds, so a roots rebuild overlapping a sidebar refresh would spawn its own subprocess where the two previously coalesced. That is a real regression on macOS, Linux and native Windows, where `getLocalProjectWorktreeGitOptions` returns `{}` and both callers land on the identical key; on WSL they already differ by distro and never shared. So the annotated listing is now the graph listing plus annotation, rather than a parallel scan of its own: `listWorktrees` awaits `listWorktreeGraph` and annotates the rows it returns. Both soften a Git failure to `[]`, so they can share one listing; strict keeps its own because it must be able to reject. The two kinds ran Git twice before and now run it once, so the overlap case gets strictly faster instead of paying for the opt-out. An annotated scan holds two in-flight entries now (its own, plus the graph listing it shares). Keeping its own entry matters: `detectSparseCheckoutCached` dedupes revalidation but not the initial fill, so two concurrent badge readers sharing only the graph scan would both probe. Per-platform delta: - macOS/Linux: fewer probes on the three call sites; one `git worktree list` instead of two when a graph and an annotated scan overlap. - native Windows, no WSL: same, and the saved subprocess is the expensive half. - Windows + WSL: the largest win. The discarded probes were 9p round-trips re-paid cold after every worktree create/remove. - SSH/relay: none. `listRepoWorktreeGraph` returns through the same provider branch as `listRepoWorktrees` before reaching local Git. - folder workspaces: none. Both return the same synthetic folder worktree. Not in this change: - The badge listing itself. It still probes, still annotates, and still keys on the distro exactly as #17932 left it. - The remaining `listRepoWorktrees` callers. They read `isSparse`, or feed rows to something that does. |
||
|
|
3777070eaf |
fix(worktree): restore the stale-cleanup signal after the module split (#18058)
* fix(worktree): restore the stale-cleanup signal after the module split Moving stale-preparation cleanup into its own module took `staleCleanupInFlight` with it, but `hasPendingWorktreeCreatePreparations` still read it directly. Both sides were green in isolation — the reference arrived on main while the split was in review — so the break only appeared once they merged, and it fails typecheck for every branch built on main. Expose the predicate from the module that owns the map, and cover the signal with a test so the idle gate's "a create is imminent" answer cannot silently regress again. * test(worktree): anchor the pending-signal test on the scan, not on await depth |
||
|
|
a7fda48fe3 |
feat(telemetry): measure macOS stale-daemon adoption and cwd denials (#18043)
* feat(telemetry): measure macOS stale-daemon adoption and cwd denials Adds two enum-only PostHog events so #17696 can be sized instead of guessed at: - daemon_adopted: once per macOS launch that keeps a daemon an earlier app launch forked (invisible to daemon_lifecycle, which only sees replacements). Carries app-version match, spawner-path class (installed app / Squirrel ShipIt cache / other / missing), the existing TCC attribution verdict, and the bucketed live-session count. - daemon_pty_cwd_denied: the symptom itself. The daemon probes the requested cwd in its own process (only its TCC context counts) and returns an additive cwdReadableByDaemon field; the app emits only when the daemon was denied AND the app can read the same path, so a missing or genuinely unreadable cwd never counts. Non-permission errors read as readable on purpose. Both emitters swallow every failure; nothing here can delay or fail daemon startup or a PTY spawn. Off macOS neither event fires. The new wire field is optional, so older daemons and clients are unaffected. * fix(telemetry): keep cwd-denial classification inside the swallow guard Read the pid record at emit time (inside the try) rather than passing the adapter's startup snapshot: a throwing app-environment read can no longer escape spawn(), and a denial after a respawn is billed to the daemon that actually spawned the PTY. |
||
|
|
d7123591ce |
perf(git): pack the loose refs Orca's own fetches leave behind (#17857)
* perf(git): pack the loose refs Orca's own fetches leave behind Orca strips git's auto-maintenance off every fetch it issues (GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS) and never compensated, so nothing in an Orca-driven checkout ever packs refs. One real machine reached 36,574 loose refs, where `git show-ref -- main` costs 5.2s and every worktree create pays for it. Add an idle-time, per-repo `git pack-refs --all --prune`, armed by the fetches that create the debt. It runs only after ten minutes of quiet on that repo, only above 1000 loose refs (probed with a walk bounded by that threshold, not by the backlog), one at a time across the whole app, at the background admission tier, and never while an agent is working, a create is prepared or in flight, a worktree removal is deleting refs, the app is quitting, or the machine is on battery. A user who set `maintenance.auto=false` or `gc.auto=0` has opted out. Measured on a 36,001-loose-ref fixture (macOS/APFS, git 2.44): `show-ref` 5.5-12.2s -> 30-49ms, `for-each-ref` 4.0-10.8s -> 43-48ms. Also fixes a pre-existing bug the split exposed: `--path-format=absolute` is ignored before git 2.31, and taking rev-parse's stdout raw collapsed every repo on such a host onto one fetch-serialization key. Refs #17828 * perf(git): make idle ref maintenance preemptible and cheaper to probe The idle veto was one-directional: it stopped a pack from starting during a create, removal, or agent work, but nothing stopped those from starting during a pack. A user-clicked Fetch, a branch delete, or a worktree removal that needed `packed-refs.lock` mid-rewrite could fail with `unable to create packed-refs.lock` -- a git error with no visible cause. Make the pack cancellable end to end. An AbortSignal now reaches the `pack-refs` child and both pre-pack probes, and `pause()` aborts what is running, waits for it to actually stop, and holds a suspension count so nothing new starts until the caller releases. Every entry point that deletes a ref takes that pause: gitFetch, gitPull, gitFastForward, removeWorktree, forceDeleteLocalBranch, prepareWorktreeCreateCheckout, addWorktree. Five more triggers close the rest of the window: battery drop, window focus, quit, the attempt deadline, and any other git command queueing for an admission slot. Judge a pack by re-probing the backlog rather than by the child's exit code. Measured in the field: another Orca session moved a branch mid-pack, git reported `cannot lock ref`, skipped that ref and packed the rest -- 36,688 loose refs down to 3. On a machine running several sessions that is the normal case, and retrying it would be wrong. Probe with one batched `readdir` per directory instead of streaming `opendir`, which issues a thread-pool round trip every 32 entries: 177ms -> 23ms on a real 36,600-ref repository, with half the event-loop lag. The walk stays strictly sequential so it can never occupy more than one of libuv's four filesystem threads. `PackRefsLockOwnership` makes a lock left by SIGKILL attributable, and only reclaims one when a marker exists, the lock is older than any pack-refs could run for, and the recorded process is gone. Refs #17828 * fix(git): wait out the packed-refs lock instead of killing the pack Measured on Git 2.55/APFS with 37k loose refs: a full `pack-refs --all --prune` takes 23-32s but holds `packed-refs.lock` for only 0.03-1.37s of it. The other ~95% is the prune phase, during which a concurrent `fetch --prune`, `branch -D` or `update-ref` succeeds every time -- per-ref locks last microseconds and git retries for `core.filesRefLockTimeout`. So the abort-on-everything design was strictly harmful. SIGTERM into the prune loop strands an empty `refs/**/*.lock` about one time in five (9/30, 5/40, 6/30 kills): `tempfile.c` opens the lock O_EXCL before `activate_tempfile()` links it into the list the signal handler walks, and a pack does ~36k lock cycles. Afterwards `update-ref -d` on that ref fails with `cannot lock ref ... File exists`, permanently. On Windows `taskkill /f` never runs git's handlers at all, so an abort inside the rewrite strands `packed-refs.lock` every time. Never signal the child. `packRefs` no longer takes an abort signal; it polls `packed-refs.lock` and reports the window through a `PackedRefsLockReporter`. `pause()` resolves when the lock is released -- bounded, and free during the prune -- while the suspension counter still blocks new attempts. Battery and window-focus become do-not-start rather than stop-what-is-running, and quit waits for the lock and lets the child finish orphaned. For strands that already exist, `PackRefsLockOwnership` now also reclaims `refs/**/*.lock` under the same three conditions plus a 0-byte check, and a lock carrying our own not-yet-reclaimable marker records `locked` with a 30min retry instead of the 6h failure cooldown -- so a Windows strand self-heals in half an hour rather than six. Reverts the git admission-scheduler event bus, which existed only to drive the abort this removes. Refs #17828 * test(git): make the ref-maintenance waits survive a loaded runner CI shard 4/8 failed on `restarts every armed countdown when the user does ref work themselves`, which passes locally. The `until()` helper spun a fixed 200 event-loop turns and then returned silently, so on a contended runner the filesystem probe had not finished and the assertion that followed failed with an unrelated message. Bound the wait by wall clock instead and throw a named error, which immediately exposed a second latent bug: the single-flight test's second wait could never succeed, because the deferred repo's retry is on a faked `setTimeout` that spinning the real loop never advances. It had been passing only because the old helper gave up quietly. Add a timer-aware variant for those, and have the countdown test await a signal the fake pack resolves rather than polling at all. Verified stable across five sequential runs and once under load average 32 with six concurrent suites. Refs #17828 |
||
|
|
fdfe354045 |
test(relay): bind test WebSocket servers to loopback
A control-handshake test that expects a timeout was instead getting
'Unexpected server response: 401' about once in fourteen runs. A slow machine
cannot turn a timeout into a 401 -- that needs a real HTTP response, so the
connection was reaching a different server.
new WebSocketServer({ port: 0 }) binds the wildcard address while the client
dials 127.0.0.1. On macOS those differ, and with SO_REUSEADDR a foreign process
can hold the more specific 127.0.0.1:P and win the connection. Caught live: a
wildcard bind took port 52584, which a running Orca app already held on
loopback, and Orca answered the probe. A listener that checks a token answers
401.
Ten constructions across seven files now pass host: '127.0.0.1', so the
reservation covers the address the client dials and a duplicate bind is refused.
Adds a ratchet, because this is not authors forgetting a convention: all 30+
.listen(0, ...) sites already pass '127.0.0.1', while 7 of 7 ws constructions
did not. ws accepts { port } alone and binds the wildcard silently, so nothing
told them. The guard pins the wildcard count, and pins separately at zero the
option shapes it cannot read -- spreads and variable option objects fail rather
than being exempted, and a recognized-construction floor catches the matcher
going blind, which otherwise reads exactly like a clean tree.
mobile/scripts/mock-server.ts stays on the wildcard deliberately: a phone
reaches it over the LAN.
|
||
|
|
8b7d778a2e |
perf(git-common): bound the fs-stat fan-out in the worktree pollers (#17839)
* perf(git-common): bound the fs-stat fan-out in the worktree pollers snapshotGitCommon and snapshotBase issued one fs op per candidate via Promise.all/a serial loop, unbounded by worktree count. At 973 live worktrees this queued ~6,800 concurrent stat calls (measured peak 6000 in a 1000-entry synthetic benchmark) onto libuv's 4-thread default pool, starving every other main-process fs operation for the scan's duration (~1s). Bound both to concurrency 8 via the existing forEachWithConcurrency helper, matching the precedent in exact-ref-probe.ts and worktree-head-identity-reader.ts. Peak concurrent stats dropped 6000 -> 48 in the benchmark; wall time was essentially unchanged (495ms -> 541ms), since the real bottleneck was never total scan time but pool starvation of unrelated work. Also make the no-native-watch and crash-fuse polling fallbacks in worktree-git-common-watch.ts / worktree-git-common-narrow-watch.ts self-calibrate their cadence: on platforms/paths where this poller is the sole change signal, a fixed 2s cadence at hundreds of worktrees approaches a permanent scan loop. Stretch the interval so a scan stays a bounded fraction (10%) of its own cadence, capped at 30s, floored at the configured base interval. Left the reconciliation backstop (fixed 30s cadence, already accepted) and checkPendingMarkers (bounded by concurrent-worktree-creation count, not total count) untouched. Fixes #17828 * perf(git-common): split the tripwire from the per-entry sweep cadence Review on #17839 found a real staleness trade-off: adaptiveCadence gated ALL detection (worktree add/remove, HEAD, dirty refs, AND per-entry commit signals) behind one stretched interval, so on the crash-fuse polling fallback the reviewer measured cadence sitting at 5.4-10s sustained and hitting the 30s cap once a single scan reached 3s at 973 worktrees -- worse than the pre-#17828 fixed ~2s+250ms baseline for signals users notice immediately (sidebar worktree list, branch labels). Split snapshotGitCommon into a cheap structural "tripwire" (readdir, worktreesDir signature, primary-file signatures, newly-appeared entries -- ~5-6 fs ops, O(1) in worktree count) that always runs on the fixed pollIntervalMs, and the O(n) per-entry sweep (commit/dirty detection) that alone is gated by the adaptive cadence via a nextSweepDueAt deadline. Existing, unchanged entries are carried over by reference on a tripwire-only tick (no re-stat), so diffing produces no spurious events; genuinely new entries are still stat'd immediately so worktree add remains real-time. This keeps everything on one ticking-flag-guarded loop (no new concurrency/race surface) -- scheduling stays fixed at pollIntervalMs; only nextSweepDueAt stretches. Also drop the adaptive-cadence seed heuristic entirely: nextSweepDueAt starts at 0, so the first regular tick after bootstrap sweeps unconditionally on its own schedule instead of guessing an initial interval from the bootstrap snapshot's duration (which could stretch the very first tick to 10-30s on a slow disk). Documented that worktree-git-common-watch.ts's adaptiveCadence call site is unreachable in production (Electron only ships darwin/linux/win32, both covered by NARROW_WATCH_PLATFORMS) rather than implying it protects real users. The reachable path is the narrow-watch crash-fuse fallback in worktree-git-common-narrow-watch.ts. Filed #17878 to track the real long-term fix: periodically retrying the upgrade back to the narrow watch after a crash-fuse trip, so the degraded/polling state doesn't need to be tuned at all once the underlying failure clears. * perf(git-common): gate per-entry structural stats on the entry-dir signature Every real git write inside a worktree admin entry (HEAD, index, config.worktree, locked) goes through a lock file + rename, which moves the entry directory's own mtime/ctime/size signature. Only `gitdir` (worktree move/repair) is rewritten in place, and that's already covered by the periodic ungated backstop (INDEX_BACKSTOP_TICKS). The previous comment claiming structural leaves "change in place every tick" was wrong; verified against git 2.55 across checkout, commit, amend, reset, ref updates, stash, worktree lock/unlock, config --worktree, and index writes. Gate all six per-entry stats behind the entry dir's own signature instead of stat-ing every leaf unconditionally every tick: an unchanged entry now costs one stat per tick instead of six, and a changed one still costs six (bounded by change rate, not worktree count). This also fixes the actual in-flight fan-out: forEachWithConcurrency(entries, 8) previously still issued 6 stats per in-flight entry (48 real concurrent ops); with the gate, warm ticks issue ~1 stat per entry, so true in-flight tracks the concurrency limit directly. This makes the follow-up adaptive-cadence machinery from the prior commit unnecessary: the crash-fuse and no-narrow-watch polling fallbacks no longer need to stretch their own cadence, since a warm sweep across hundreds of worktrees is now cheap regardless of interval. Revert both call sites to a fixed pollIntervalMs and delete the adaptive-cadence option, the split tripwire/sweep cadence, and the seed heuristic — none of it earns its complexity once the real per-entry cost is fixed at the source. Per-entry staleness on the crash-fuse path returns to a fixed 2s + 250ms debounce instead of the previous 5.4-30s adaptive stretch. Refs #17828 |
||
|
|
e89321192a |
perf(worktree): batch remote conflict probes, re-arm the prepared checkout (#17829)
* perf(worktree): batch remote conflict probes, re-arm the prepared checkout A repo with many remotes paid one `git show-ref --verify` subprocess per remote on every branch-conflict check during create. Ask one `git cat-file --batch-check` over stdin instead; it reports a missing ref as data rather than a failed exit, so a batch stays as decidable as the per-ref probe. Hosts that cannot feed stdin, and undecided batches, still fall back to the per-ref path. The prepared checkout was single-use, so the second create in a row paid the full cold `git worktree add`. Re-arm it in the background after one is consumed; the existing TTL and preparation limit still bound it. The create timing recorder existed but its phases were never emitted and did not cover preflight, leaving a multi-second gap in the trace with no attribution. Add `resolve_name`/`prepare_push_target` phases and record the breakdown, plus the unattributed remainder, on the create span. * fix(worktree): format the conflicting review number eagerly for the create error * perf(worktree): re-arm a prepared checkout only for a burst of creates Re-arming after every consumed preparation spends a full checkout and ~200MB of disk on a user who created one worktree and stopped, then pays an unexplained delete when the TTL expires five minutes later. Track when each preparation key was last consumed and only replace it when a second create lands inside the burst window, so the warm second create is still free and an isolated create costs nothing. * fix(worktree): address review findings on the create-path batching Three findings from PR review: The `batched.found` fallback in the remote-conflict probe was unreachable — a present ref is decisive, so `found` never survives with `unknown` set, and the guard above already returns that case. `rearmPreparation` checked for an existing preparation before recording the consume, so a prefetch that re-armed the key while create finalized swallowed the timestamp and made the next create look isolated when it was really mid-burst. Create runs some phases concurrently, so summing phase durations double-counted overlap and understated `unattributed_ms` — the one number that matters when a create is slow for no visible reason. Measure the union of the phase intervals instead. * refactor(worktree): move stale-preparation cleanup into its own module The preparation module crossed the 300-line budget. Crash recovery is a separate concern from the pool itself — it discards preparations another process left registered, single-flighted per repo and runtime so a burst of arming calls shares one worktree listing. * test(worktree): make the re-arm test able to fail The burst test armed a preparation manually after the second consume, so the third checkout appeared whether or not the re-arm produced it — the assertion passed with re-arming disabled. Drop that arming call so the third checkout can only come from the re-arm, and assert the consume results rather than discarding them. |
||
|
|
894c5fe36a |
test(orchestration): fail loudly on an unexpected second detection call
The mock overwrote resolveDetection on every call, so a second invocation would strand the first promise and hang to a 30s timeout instead of naming what changed. A test that hangs rather than fails is how a real bug gets mistaken for infrastructure noise. |
||
|
|
4efc86a33c |
feat(app): open Markdown files from the OS in the floating workspace (#17906)
* feat(app): open Markdown files from the OS in the floating workspace Registers Orca as a Markdown handler on macOS, Windows and Linux, and opens an OS-handed .md/.markdown/.mdx file as a floating-workspace editor tab — the one editor surface that needs no project. Works cold-start and when Orca is already running. Main buffers the paths and both pushes to a live renderer and answers a pull on renderer mount, mirroring SkillShareDeepLinkState. The buffer is only released once delivery is possible: the renderer's pull is what proves its ui:openMarkdownFiles listener is attached, because a push into a window whose renderer has not subscribed is dropped by Electron with no error. Both the push and the pull restore an undelivered batch, and a renderer reload clears the latch so the fresh renderer re-proves itself. Paths are stat'd and proven to be files before authorizeExternalPath sees them. Windows association is registered by hand in the NSIS include rather than through electron-builder's `fileAssociations`: app-builder-lib emits APP_ASSOCIATE, whose first line overwrites Software\Classes\.md's default value with no backup — silently taking .md from whichever editor owns it, for every existing user on their next update — and APP_UNASSOCIATE never restores it. The hand-rolled registration is additive (ProgID + OpenWithProgids + SupportedTypes) and leaves the user's default alone; verified end to end on a real Windows 11 host. Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com> Co-authored-by: Jaydev <java-jaydev@users.noreply.github.com> Closes #10138 * fix(os-open): register the new listener in the IPC inventory, and guard a non-array payload CI caught two things the local run did not. useIpcEvents-lifecycle.test.ts is an inventory of every App-lifetime IPC listener and the exact order they register in; ui.onOpenMarkdownFiles now appears there, positioned after the workspace-shortcut bridge's last listener, which is where it actually registers. Chasing that failure surfaced a real gap: the pending-open payload crosses the preload boundary, so a stale or mismatched preload can resolve with something that is not an array, and reading .length off it threw inside the promise chain instead of failing at the boundary. Array.isArray now gates it, with a regression test. |
||
|
|
d48ab96144 |
test: stop two suites failing for reasons unrelated to their subject
The zsh wrapper test relocated into a fixed-name directory in shared temp, so a single killed run left it behind and every later run on that machine failed with ENOTEMPTY, permanently. Makes the name unique while keeping the non-ASCII component the test exists for. The palette budget asserted a helper named percentile95 that returns sorted[floor(n * 0.95)] -- the maximum of the batch. Asserting worst-case wall-clock under a parallel runner measures scheduler preemption: the asserted quantity ranged 123-343ms across 20 saturated windows and blew the 220ms budget in 6 of them, while the fastest sample of those same batches held at 19-32ms. Asserts the fastest sample instead and adds a deterministic fan-out ceiling, so the guard counts work rather than time. Budgets are unchanged. |
||
|
|
f2fa4a7754 |
fix(worktrees): drop an unreachable runtime arm from the retirement gate
`findExactRepoOwner` already refuses a repo carrying both a runtime `executionHostId` and a `connectionId` -- `resolveRepoOwnershipEvidence` calls that pair contradictory, and one non-owned candidate voids the whole lookup. There is also no way for a `connectionId` to yield a `runtime:` host id, since `toSshExecutionHostId` always emits `ssh:`. The runtime arm of `connectionMatchesHost` could therefore never decide anything, and the test meant to pin it was passing through the contradiction gate instead. Keep the SSH arm, which does gate, and record where the runtime refusal actually comes from. Unreachable code on a destructive path reads as a guarantee it is not making. Refs #17776 |
||
|
|
398aeccdfe |
fix(worktrees): retire runtime-host metadata a scan proved gone
A paired client's WorktreeMeta for a runtime host is exempt from gcStaleWorktreeMeta -- that GC skips any row that is not local on both the repo and the meta's hostId -- so a scan-proven removal is the only thing that ever retires one. Both halves of that path were gated to `ssh:`, so the client kept a row for every remote worktree it had ever seen and dropped none. The renderer already computed the removals for runtime hosts and purged its own in-memory state with them; only the persisted half bailed. Widen it, and the matching main-side handler, to runtime hosts. `OffHostExecutionHostId` names the set precisely: the hosts the local-only GC skips. Also require `source === 'git'` before retiring anything. `session-fallback` reports `authoritative: true` but is the truncated, visibility-filtered `worktree.list` reply from a host too old for `worktree.detectedList`; its omissions are no evidence a checkout is gone. That guard did not matter while this only ran the in-memory purge, and does now that it deletes rows. A repo that reaches its checkouts over a connection is still never condemned under a runtime host id -- the host that executes owns that verdict. Refs #17776 |
||
|
|
05a7d39058 |
test(runtime): make the off-host sweep case a real control
The row was stamped `ssh:build-box`, which `captureNativeLocalWorktreeMetadataScanExpectation` filters out before the prune runs -- so it survived whether or not any host gate existed and pinned nothing. Stamp it `local` so it is a genuine prune candidate whose directory really is missing, and make the fixture identical to the first case apart from `connectionId`. That pairing is what proves the behavior: the same fixture without a connection loses the row. Deleting any single gate would not show it, since four independent checks derive from `connectionId` on this path. Refs #17776 |
||
|
|
ff8b4d08ab |
fix(runtime): sweep missing local worktree metadata on the host that owns it
`pruneMetadataMissingFromAuthoritativeLocalScan` had exactly one caller:
`ipcMain.handle('worktrees:listAll')`. A headless runtime host has no
renderer, so it never ran, and that host's `worktreeMeta` grew without bound
even for its own local repos -- 129 of 139 rows dangling on the profile in
#17776.
Run it from the runtime's own detected listing instead. That is the same
trigger on the same evidence: `listDetected` already prunes lineage on an
authoritative scan, and a paired client refreshing a remote repo calls
`worktree.detectedList`, so the host now sweeps exactly when the desktop
would have.
The expectation is captured before the scan, because listing can mutate
metadata synchronously before its first await. WSL-routed repos are excluded
for the reason the desktop listing excludes them: the listing runs in the
distro and reports Linux paths while metadata can hold UNC ones, and v1
cannot prove those aliases equivalent. A runtime needing repair throws rather
than resolving routing, which is likewise no basis for deleting rows.
The prune's own gates still apply, so an SSH- or otherwise off-host repo is
never swept from a local stat -- the execution host owns that verdict.
Refs #17776
|
||
|
|
1a11f82fcc |
test(persistence): cover each session scalar as an orphan's only residue
`activeWorktreeId`, `activeWorkspaceKey` and `activeWorktreeIdsOnShutdown` are pruned by bespoke rules rather than by owner key, so no owner-key loop reaches them and each has to be able to seed the sweep alone. The sweep already handles all three -- the census seeds from them and `removeRepoFromWorkspaceSession` clears them -- but nothing pinned it, and dropping that seeding turns all three cases red. The `activeWorkspaceKey` case uses the canonical `worktree:<id>` form, so it also covers unwrapping the workspace key before the repo id is visible. Refs #17776 |
||
|
|
87f3e907dd |
test(persistence): assert the sleeping-agent cleanup reached disk
The self-clearing check loaded a second store, but that constructor runs the sweep itself. If the first flush had not persisted the cleanup, the second load would have redone it in memory and the assertion would have passed without meaning anything. Read the profile back and assert the map is empty there first. Refs #17776 |
||
|
|
2f105b23d1 |
fix(persistence): sweep sleeping-agent-only residue and stop mis-seeding orphans
Review found three holes in the load-time sweep. `sleepingAgentSessionsByPaneKey` and `terminalSurfaceTombstonesByPaneKey` are pruned by the worktreeId they name, not by their own key, but `pruneWorktreeStateForRepo` only collected owner keys from `worktreeMeta` and `lastVisitedAtByWorktreeId`. An orphan whose only residue was a sleeping agent therefore survived the sweep and re-seeded it on the next load, so the store never self-cleared and every launch scheduled another save. Collect owner keys from those records too, which fixes `removeProject` for the same shape. `ownerKeyBelongsToRepo` is restored to its original body. Reordering its two readings was not behavior-preserving as claimed: for a repo named `folder` or `worktree`, checking the workspace-key reading first flips the result. The census now uses `ownerKeyWorktreeIds`, which returns both readings, and seeds only when neither names a live repo -- seeding one reading of a key whose other reading is live would hand the removal pass a live row to delete. Seed from `activeWorktreeId`, `activeWorkspaceKey` and `activeWorktreeIdsOnShutdown`, which are pruned by bespoke rules and so were reachable by no owner-key loop, and record why `terminalTopologyRevisionByRepoId` stays excluded. Refs #17776 |
||
|
|
a2aea5d0b0 |
fix(persistence): sweep rows owned by deregistered repo ids at load
Deregistering a project stranded every row it owned. Each pruning path is gated on the repo still being in `state.repos`, so once an id leaves the catalogue its metadata, identity aliases, lineage and session rows became unreachable forever -- and on a paired client they rendered as phantom worktrees under an "Unknown" project. Reconcile against the repo catalogue on load instead: any repo id that owns rows but is absent from `state.repos` has its rows removed through the same path `removeProject` uses. Host-independent and session-independent, because an orphan has no owner that could object -- which is also why this reaches a client's mirror of a remote host's session partition, something no local removal can do. Only a full `<repoId>::<path>` locator seeds the orphan set; bare keys can be folder workspace ids or repo-keyed revisions, and guessing wrong there would delete live state. `retiredWorktreeNamesByRepo` is deliberately untouched so a re-added repo cannot reissue a name onto a cwd that still holds a prior occupant's agent state. Test fixtures that wrote worktree rows without registering their repo were relying on orphans surviving a reload; they now register the repo they name. Refs #17776 |
||
|
|
519af49a58 |
fix(dev): keep the shared Electron dist writable for the dev app
pn dev crashes on macOS in any worktree that adopted the shared Electron dist. publishSharedElectronDist marks the cache entry read-only, which hardlink sharing needs, but clonefile preserves mode -- so the dist lands 0555, the dev runner copies it into out/electron-dev unchanged, and the first plutil -replace on Info.plist fails with a permission error. The shipped zip has that file at 0644; on disk it is 0555, so the mode is ours, not upstream's. copyPrivateTree now restores write permission. Its contract is a private tree the caller goes on to patch, and its one production caller is the dev runner. The test that should have caught this ran the wrapper with stdio: 'ignore', so a hard crash presented as a bare 20s timeout. It now captures the wrapper's output into the failure message, and waits long enough for the two synchronous swiftc builds and a codesign --deep over ~280MB that precede the assertion. |
||
|
|
93a258c81d |
fix(worktrees): reclaim orphaned pr-* fork remotes (#17842)
* fix(worktrees): reclaim orphaned pr-* fork remotes pr-* remotes Orca adds for fork-PR worktrees were only ever pruned by a single worktree's own removal, and only when that removal had complete provenance metadata, no branch pinning it, and actually ran through Orca. Legacy metadata missing remoteCreated, "preserve branch on delete" pinning the remote via branch.*.remote config after the worktree is gone, and worktrees removed outside Orca entirely all left the remote behind forever -- one real user accumulated ~50 leaked remotes this way. Add a repo-scoped reconciliation sweep that inverts the existing cleanup predicates over every pr-* remote instead of one removal, reusing sameGitHubRemoteUrl/hasBranchConfigUsingRemote so no new safety logic is introduced. It only touches a remote some worktree's persisted pushTarget explicitly recorded Orca creating (remoteCreated: true) -- naming and URL shape alone are not proof of provenance. Runs opportunistically alongside existing single-target cleanup (including RuntimePreservedBranchCleanup's force-delete path), rate-limited per repo, and fire-and-forget so it never adds latency to the worktree-removal path a user is waiting on. Fixes #17828 * test(worktrees): set a local git identity in the pr-remote fixture CI runners have no global git identity, so `git commit` in the fixture repos failed with "Author identity unknown" -- only passed locally because dev machines have one. Set user.name/user.email (plus commit.gpgSign and core.hooksPath, matching src/main/git/repo-remote-drift-real.test.ts) as local repo config in both the main and cloned "fork" fixture repos, so the test is independent of the runner's global config, signing setup, or hooks. |
||
|
|
7873f73d80 | fix(daemon): keep attach cancellation behind client timeout (#17816) | ||
|
|
28214e1ea1 |
fix(linux): stop re-extracting the AppImage on inode metadata churn
The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime. ctime moves on any inode metadata write -- `chmod +x`, which every AppImage user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup restore -- none of which alter a byte of the payload. Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime identical and moves ctime alone, so the key changed and the next launch paid a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it already had, then pruned the old generation. Key on content identity instead. An in-place content change moves mtime and almost always size; a replacement moves the inode. The existing replace-in-place test still passes. |
||
|
|
a310150e6a |
fix(linux): bound the CLI registration lock wait
`retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per attempt an IPC-driven registration could hang ~16 minutes against a wedged holder with no feedback. A legitimate holder is bounded by the extraction timeout, so wait that plus slack and then fail with a message naming the lock file, rather than hanging. `maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile. |
||
|
|
1767858ea7 |
fix(linux): reclaim superseded AppImage payloads and packaged symlinks
Pruning removed 3215 of 3216 files from a superseded generation and always
stranded resources/app.asar, leaking ~105 MB per version update. Electron's
asar shim reports a *.asar file as a directory, so the recursive remove tried
to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it.
Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and
519M again once the payload is actually reclaimed.
removeExtractedAppImagePayload holds process.noAsar for the removal, counted
so overlapping removals cannot hand the shim back early, and the prune site
now warns with the path instead of swallowing the rejection. All three
removal sites use it -- staging cleanup and displaced roots leaked the same
way.
Also reclaim symlinks left by a packaged deb/rpm install, which the
extracted-cache-only rule turned into a hard conflict on a deb -> AppImage
migration, and name the remedy in the conflict error.
|
||
|
|
bbd2047066 | refactor(linux): import bundled launcher directly | ||
|
|
a2c8859f76 | fix(linux): accept extracted AppImage runtimes with APPDIR only | ||
|
|
d19a8cdf86 | fix(linux): fence AppImage terminal shim mounts | ||
|
|
0079fe2fa8 | test(cli): assert registration lock serialization | ||
|
|
4c24a28df0 | refactor(linux): trim AppImage CLI registration seams | ||
|
|
da4a83bd22 | fix(linux): give the CLI one entrypoint by extracting the AppImage once | ||
|
|
8fef5820ff |
fix(renderer): restore behavior the UI split dropped
The oversized-UI-surfaces split was cut from a stale branch and reverted merged work. getClientCreationActionPolicy entered Terminal.tsx in #13909 and left in the split, taking six call sites with it, so every action-time creation gate in the terminal and floating surfaces was gone. Restores those and the other behavior the split dropped, each ported from the pre-split reference: - Cmd/Ctrl+S dispatched a bare Event with no detail, so the only listener always bailed on detail?.fileId and the chord never saved. Its resolver had been left orphaned, imported by nothing but its own test. - Terminal and floating create actions lost their availability gates, their toasts, and their catch handlers; one path throws on unavailable, so it was a silent unhandled rejection. - Both outermost workbench wrappers lost the browser guest paint retention branch, and the census entry covering them was deleted in the same commit. - The Space Analyzer header counted omitted items the list no longer rendered, and a worktree whose items were all omitted showed the empty state. - The terminal root lost its tab topology projection, so every tab-title update re-rendered it. - The titlebar tab bar stopped being passed clientHostedBrowserRows, leaving client-hosted pages uncloseable before a worktree has a layout. - Parking diagnostics lost their exempt-route counts and crash breadcrumb. - A suppressed inherited-terminal frame began buying a freshness scan the pre-split early return skipped. Adds regression tests for each, all verified to fail against the pre-fix code. Restores three deleted assertions whose invariants are still live, and replaces a concatenated source-boundary fixture with per-module pinning so a symbol is again asserted against the module that must own it. Deletes three orphaned trees the splits stranded: a duplicate ResourceUsage surface, cmd-j-match-relevance, and an agent-session claim-key module whose logic the record store already owns. Makes two non-recursive test walkers recursive, one of which silently skipped every nested CLI handler group. |
||
|
|
8cc7634051 |
refactor: name modules for their domain instead of 'helpers'
Renames seven -helpers modules for the concept their functions operate on, and splits three that were genuine grab-bags -- each had a clean cleavage along its importers, which is the signal AGENTS.md describes for a file holding more than one responsibility. Leaves keybindings/definitions-core-1..4 alone: definitions.ts spreads them in order, so their concatenation order is the command palette order and regrouping them thematically would be a user-visible change. Records that reasoning in a comment so it is not re-litigated. |
||
|
|
fc68d2c3a2 |
refactor(preload): name bridge modules for what they expose
The split named these -part-N, which says nothing. Renames each for the group of bridge methods it actually exposes and folds the single-method window-reveal module into the window-controls module it belongs with. Verified by walking the composed contextBridge surface before and after: 1060 keys, identical nesting and value types, zero delta. The bridge modules carry no satisfies annotation, so a dropped key here is a runtime error in the renderer rather than a typecheck failure. |
||
|
|
02ffd40edc |
fix(ssh): stop treating an unanswered native-deps probe as broken deps (#17979)
probeRequiredNativeDeps mapped any thrown error to available:false, which both triggered the repair and fed resetDeps — so one dropped exec channel rm -rf'd node_modules/node-pty on a healthy relay and forced a node-gyp source build. Verdicts are now ok / blocked / unverifiable; only an answered probe may repair, and only an answered probe may name reset deps. |
||
|
|
5150c52045 |
fix(dev): skip blocking keychain diagnostic (#17877)
* fix(dev): skip blocking keychain diagnostic * fix(dev): preserve forced secret protection report --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
69fff5eaa3 |
fix(runtime): defer websocket heartbeat startup probe (#17810)
* fix(runtime): defer websocket heartbeat startup probe * fix(runtime): defer heartbeat probes until websocket auth |
||
|
|
26dfa46aa9 |
fix(wsl): resolve sparse-checkout and diff-stamp gitdirs through the caller's distro (#17932)
Two main-process `resolveGitDir` call sites dropped the WSL distro their caller
already held, so they could only resolve a gitdir pointer whose spelling carries
its own translation: a `//wsl.localhost/<Distro>/...` base, or a `/mnt/<letter>`
drvfs pointer that maps to a drive letter on its own.
The layout that needs the third case is a repo inside the distro's filesystem
with its worktrees on the Windows drive. `git worktree list` reports the worktree
as `/mnt/c/wt/x`, which the listing translates to `C:\wt\x` — a base that no
longer names a distro — while the `.git` gitfile beside it points at
`/home/me/repo/.git/worktrees/x`, which has no drive to derive. Win32 then treats
that pointer as absolute and reads a path that names nothing:
- `detectSparseCheckout` stats `info/sparse-checkout` under the fabricated path,
always misses, and reports the worktree as non-sparse — no sparse badge, and
the file list claims files that are not on disk.
- `readWorktreeDiffStamp` reads HEAD under the same path, gets nothing, and
returns null. Null is the safe answer ("cannot prove unchanged"), but it
retires the settled-diff cache for every file in that worktree, so each diff
respawns Git.
Both callers already have the distro: the listing threads its
`GitWorktreeExecOptions` to `annotateSparseCheckoutStatus`, on through
`detectSparseCheckoutCached` (the annotation cache added by #17859) and its
background revalidation probe, and finally to `detectSparseCheckout`; and
`file-diff` already forwards its `GitRuntimeOptions` to `readWorktreeDiffStamp`,
which now forwards it to `resolveGitDir` as well.
`resolveGitMetadataPath` still prefers a UNC base's distro and still tries drvfs
before the caller-named distro, so nothing that resolved before resolves
differently.
The cache hop matters twice over. It is the only remaining caller of
`detectSparseCheckout`, so without threading it the fix would not reach the
probe at all. And the cache is where the bug turns sticky. #17859 keyed entries
on `repoPath` + `worktreePath` alone, on the reasoning that the distro is a
property of the repo and so every read for a given `repoPath` carries the same
one. That invariant does not hold. `listRepoWorktrees(repo)` is called with no
options at all from the filesystem-auth root rebuild
(`registered-worktree-roots-cache.ts`, reached from `ensureAuthorizedRootsCache`
on any auth check with a dirty cache) and from the local worktree-ownership
check in `filesystem-worktree-helpers.ts`. Both land on the *same* key as the
distro-carrying listing, because `translateWslOutputPaths` derives the distro
from the cwd spelling before falling back to `options.wslDistro`, so a
UNC-spelled repo path yields the identical `C:\...` worktree row either way.
Measured on Windows in one process, branch build: a distro-less read followed by
a distro-carrying read reported the sparse worktree as non-sparse both times.
So `wslDistro` now joins the cache key -- trimmed and lowercased, matching how
the rest of the codebase compares distro names, and appended last so the
repo-scoped prefix delete still matches every variant. The per-path invalidate
becomes a prefix delete for the same reason, dropping every distro variant of a
removed or moved worktree.
Keying on it closes both halves of the defect. A correct caller can no longer be
served an answer derived without the distro it supplied. And because the entry a
reader reaches is now selected by the same distro it would re-probe with,
`revalidateInBackground` can no longer re-derive a warm entry under weaker
options -- which mattered on its own: a distro-less reader crossing the
five-minute window would otherwise flip a correct `true` to `false`, and the
resulting change notification runs the registered invalidator, clearing the
whole repo's cache and re-probing every worktree cold, on a five-minute loop.
Cost of the extra key dimension is bounded by the number of distinct distros a
given repo is actually read under: one where a distro is threaded everywhere,
two while the distro-less callers above still exist. Entries are still
repo-scoped, and both clears already sweep by prefix.
Per-platform delta:
- macOS/Linux: no change. Guest-pointer translation is gated to win32 and a
caller-named distro is ignored off Windows; no caller supplies one there, so
the cache keys and probes exactly as before.
- native Windows, no WSL: no change. `wslDistro` is undefined, so the resolver
takes exactly the branches it took before and every read keys on the same
empty distro component, so the cache behaves exactly as it did.
- Windows + WSL, UNC-spelled worktree: no change. The base already names the
distro and outranks the caller's.
- Windows + WSL, drvfs-spelled worktree with a drvfs pointer: no change. The
drive-letter derivation still runs first.
- Windows + WSL, drvfs-spelled worktree with a non-drvfs pointer: the sparse
badge appears and the settled-diff cache starts hitting. Both previously
failed toward "not sparse" / "do not cache", so neither can now serve a stale
answer, and the distro-less listings no longer share the badge's cache entry.
- SSH/relay: none. Those paths return through the provider branch before
reaching either function.
- folder workspaces, GitLab: none. Neither is on these code paths.
Not in this change:
- `readRepoCommonDirFromDisk` (worktree-listing). Passing the distro there is
inert: a repo root's `.git` is a directory, so the gitfile-pointer branch never
runs, and when `repoPath` itself is guest-spelled the preceding `stat` already
fails — which no `resolveGitDir` option can fix.
- The two `findExistingWorktreeSymlinkPaths` calls on the removal paths. Both
receive `registeredWorktree.path` from `listWorktreesStrict`, which already
translates every row out of the guest namespace, so the distro would be a
no-op. The `removeWorktreeLinkedPaths` unlink beside them is untranslated too,
so a half-threaded fix would only move the refusal from Orca's preflight to
`git worktree remove`.
- An absolute `commondir` payload, which `resolveGitCommonDir` still resolves
untranslated. Git writes that file relative in the layouts above, and the
failure direction is unchanged.
- Giving the two distro-less `listRepoWorktrees(repo)` callers a distro. Neither
reads `isSparse` -- both use only `worktree.path` -- so the distro would buy
them nothing they consume, while resolving a project runtime inside the
filesystem-auth rebuild would put a call that throws on `repair-required`
behind a catch that skips the whole repo's authorized roots. The cache key
makes their reads harmless; skipping the annotation for callers that never
read it is a separate, larger change. The third no-options call in
`hosted-review.ts` is inside the `repo.connectionId` branch and returns
through the SSH provider, so it never reaches this cache.
|
||
|
|
f27a30d1b6 |
fix(ai-vault): avoid large Codex scan timeouts (#17889)
Agent Session History exceeded its 130-second deadline on large local Codex histories. Three costs combined: excluded worker transcripts were recognized on their first line but still drained to EOF (1,011 files / ~18.3 GiB on the reported corpus), large ignored records were fully decoded and JSON.parsed, and the persisted parse cache was discarded on every app update. - Stop resumable reads the moment `session_meta` marks a worker transcript. - Skip decode + `JSON.parse` for records the parser only feeds to the timeline. The skip set is the complement of what `consumeCodexRecordLine` reads, and applies only above the bounded prefix limit, so a long opening prompt (which is the session title) still takes the exact parser. - Prove cross-volume rollout aliases from a bounded `session_meta` read routed through the WSL transcript FS gate, carrying the scan's AbortSignal, fanned out across contested candidates with bounded concurrency. - Make parse-cache schema 2 the semantic compatibility boundary so an update no longer forces a multi-gigabyte cold scan, fenced by a build-time ratchet on the persisted session shape. - Report early-stopped transcripts as their own `aiVault.scan` attribute. Verified on macOS, Ubuntu over SSH, and Windows: read volume drops 336 -> 49.5 MiB identically on all three; the Windows failing-test set is byte-identical to main. Reported corpus: 130s timeout -> 58.6s, 244 sessions, 0 issues. Fixes #17888. |
||
|
|
f176e49478 |
fix(git): narrow fork-remote fetch refspecs to tracked branches (#17887)
* fix(git): narrow fork-remote fetch refspecs to tracked branches git remote add with no -t writes the wide +refs/heads/*:refs/remotes/<name>/* refspec, so any later plain `git fetch` (user, agent, or Orca's own Fetch action) re-imports a fork's entire branch set and its tags -- one real machine had ~50 leaked/wide fork remotes producing 59,716 remote-tracking refs. Mint and reuse now pin -t <branch> --no-tags; a rate-limited sweep narrows and cleans up remotes minted before this fix; gitFetch self-heals when a narrowed remote's tracked branch is later deleted upstream. Refs #17828 * fix(git): soften narrow fork-remote refspec against deleted upstream branches A bare `git fetch` in a worktree checked out on a fork-PR branch resolves to the pr-* remote via branch.<name>.remote -- not origin -- making it the dominant fetch shape in Orca's terminal-centric, agent-driven usage. The previous literal-refspec design hard-failed that fetch ("couldn't find remote ref") the moment the tracked branch was deleted/renamed upstream, which is not the narrow edge case it was first described as. Switch to a trailing-`*`-suffixed refspec source/destination (refs/heads/<branch>*:refs/remotes/<name>/<branch>*). Verified against real git: this restores wildcard zero-match tolerance (silent no-op instead of a hard failure) and lets plain `git fetch --prune` reclaim the stale ref once the branch disappears, at the cost of also matching sibling branches that share the literal name as a prefix -- a materially smaller widening than the original unbounded-import bug. Also close a race with #17842's orphaned-pr-remote reconciliation sweep: both sweeps read the same worktree-metadata store to pick candidate remotes, so reconciliation can `remote remove` a remote this migration is concurrently narrowing. `ensureRemoteTracksBranchNarrowly`'s plain `config --add` would silently resurrect a url-less config section in that case; re-check `remote.<name>.url` (via the new `remoteHasUrl`, plumbing rather than porcelain `remote get-url`, which falls back to echoing the remote name as a bogus URL) after the narrowing writes and remove the section if it's gone. * fix(git): update stale fork-remote mint assertions for -t/--no-tags and wildcard-suffix refspec Four test files still asserted the pre-#17828 remote-add shape or the literal (non-wildcard-suffixed) fetch refspec from before the deleted-upstream-branch softening commit, so CI went red on that HEAD: - worktree-push-target-refspec-real-git.test.ts: the migration fixture asserted a hardcoded tracked-ref count before narrowing. Under git >= 2.44, `followRemoteHEAD` auto-creates a `refs/remotes/<name>/HEAD` symref on the first fetch matching the full wildcard refspec, adding one untracked ref. Made the count/assertions robust to that ref's presence instead of hand-tuning the constant per git version. - worktrees-wsl-runtime-routing.test.ts: assertions predated both the `-t <branch> --no-tags` mint change and the wildcard-suffix refspec change; updated to the full, correct call sequence and confirmed the WSL routing options (cwd, wslDistro) are threaded to every call. - worktrees-create-metadata-persistence.test.ts and orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts: same class of staleness, found via CI job log cross-referencing rather than being explicitly flagged. Verified out of scope: the SSH fork-remote mint path (prepareWorktreePushTargetSsh) is untouched by this PR -- it never persists a `remote.<name>.fetch` refspec at all, using provider.fetchRemoteTrackingRef for a targeted per-branch fetch instead -- so worktrees-ssh-fork-push-target-remote.test.ts needed no change. * fix(git): migrate pr-* remotes with zero worktree-metadata trace too The migration sweep's candidate discovery was purely metadata-driven (store.getAllWorktreeMeta()), so a pr-* remote whose every referencing worktree was removed outside preserve-on-delete (metadata purged, not just the worktree) was permanently invisible to it and stayed on the wide default forever. Field data from a manual migration run against a real user's repo (31 pr-* remotes, 34,637 tracking refs, only 18 actually needed) found exactly this: 15 of 31 remotes had no branch pinning them at all. Widen discovery to every pr-* remote git reports on disk, in addition to metadata-derived candidates. For a remote with no branch provenance from either metadata or surviving branch.*.remote/.pushRemote config, there's nothing to narrow *to* -- clear its fetch refspec entirely instead (stays pushable, imports nothing on a plain fetch), gated on it still carrying the untouched stock wide default so a user's own custom pr-*-named remote isn't touched. Removing the remote outright stays #17842's job. Adds clearForkRemoteFetchRefspec (fork-remote-refspec.ts), 3 new mocked-exec tests, and a real-git integration test proving a subsequent plain `git fetch` on the cleared remote imports nothing. |
||
|
|
84584b61d0 |
perf(git): cache sparse-checkout annotation on worktree listing (#17859)
* perf(git): cache sparse-checkout annotation on worktree listing `git worktree list` never reports sparse-checkout state, so every listing paid a per-worktree fs.stat + config read to detect it -- measured at ~9x the cost of the `git worktree list` call it decorates on a 1000-worktree repo. Cache the result per worktree path, invalidated by the existing worktree-change invalidator registry plus explicit remove/move hooks, with a 5-minute reconcile window bounding the one unwitnessed edge case (external `git sparse-checkout` toggle with extensions.worktreeConfig off), matching the precedent already accepted in readRepoWorktreeAdminFingerprint. * perf(git): normalize/scope sparse-checkout cache keys, add SWR Address independent-review follow-ups on the sparse-checkout annotation cache (#17859): - Extract canonicalWorktreePath() from areWorktreePathsEqual and key/invalidate the cache through it on both read and write, closing the disclosed path-spelling P2 outright instead of leaving it as a residual risk. - Scope cache entries and clears by repo path (derived from the invalidator registry's repoId via a store lookup, falling back to a full clear when the repo can't be resolved), so churn in one repo no longer evicts a sibling repo's warm cache. - Replace the hard 5-minute cutoff with stale-while-revalidate: past the window, callers get the cached value immediately while a deduplicated background probe corrects it and, on a flip, drives the existing worktrees-changed notification -- collapsing visible staleness from the full window to one refresh cycle at zero added listing latency. Also corrects a stale claim in the original PR description: newer Git does emit a `sparse` porcelain line (which annotateSparseCheckoutStatus already skips), but Orca's Git 2.25 compatibility baseline predates it, so the fallback detection this caches remains necessary. * fix(git): stop background sparse-checkout revalidation resurrecting invalidated entries Readiness-loop finding: a stale-while-revalidate probe in flight when a worktree is removed/moved (or a repo's cache is cleared) would still write its result back afterward, resurrecting an entry that was deliberately dropped. Guard the write with a presence check so an invalidated key stays absent until the next real read. * fix(git): identity-check the sparse-checkout SWR write-back guard The has()/presence guard from the previous commit only proved some entry existed at the key, not that it was the one this revalidation started from. A worktree removed and re-created at the same path while a background re-detect was in flight would repopulate the key with a fresh cold read, and the stale in-flight result would then overwrite it -- exactly the race greptile (P1) and pullfrog both flagged as still open. Compare the map's current entry by reference to the entry captured when the revalidation began; a mismatch means something else (invalidate, clear, or a fresh cold read) replaced it, and the stale result must not be written back. Added a regression test that fails against the old has() guard and passes with the identity check: invalidate and repopulate the key with a different value mid-flight, then let the stale revalidation settle and assert the fresh value survives. |
||
|
|
9542b45d99 |
fix(wsl): resolve conflict and working-tree probes in the host path namespace (#17895)
Git running inside a WSL distro writes `.git` gitdir pointers, and answers
`status --porcelain`, in the guest namespace. Node reads both back in the
Windows main process, where `/mnt/c/repo/.git` resolves to `C:\mnt\c\repo\.git`
and `/home/me/wt` names nothing at all. Four fs probes were built on those
fabricated paths and always came back "absent":
- `detectConflictOperation`'s four marker probes, so merge/rebase/cherry-pick
badges silently went missing.
- `parseUnmergedEntry`'s compat existence check, so every `deleted_by_us` /
`added_by_them` conflict rendered as 'deleted' regardless of the working tree.
- `findExistingWorktreeSymlinkPaths`' `lstat` from status, so Orca's own shared
symlinks (node_modules and friends) showed as user changes.
- the same `lstat` from the hosted-review dirty preflight, which fails closed:
an unreadable shared symlink read as uncommitted work and blocked PR/MR
creation outright.
`resolveGitDir` computes the host spelling of the worktree once and uses it for
both the gitfile read and the pointer resolve, so a guest-spelled worktree path
is reached at all, and a relative pointer (`worktree.useRelativePaths`, git
2.48+) resolves against a spelling Win32 understands. The pointer itself now
goes through the already-landed `resolveGitMetadataPath`, and the function gains
an optional `{ wslDistro }` for a caller whose base path does not encode a
distro. `detectConflictOperation` forwards it, and the three callers that reach
it -- status-read, the runtime RPC, the `git:conflictOperation` IPC -- pass the
git options they already hold. The return type stays `Promise<string>`.
`resolveWorktreeHostPath` is the same rule applied to a worktree path, used by
status-read for the two working-tree probes and by the review preflight. Both it
and `resolveGitMetadataPath` now treat only a single-leading-slash path as guest
namespace: `//wsl.localhost/...` is already a host UNC spelling, and translating
it prepended a second share prefix.
`readWorktreeDiffStamp` needed the same one-namespace guarantee, since moving
translation inside `resolveGitDir` would otherwise make its HEAD and index real
while the working-tree stat stayed fabricated, letting a settled diff survive
every edit. #17896 landed that change first, so it is no longer in this diff;
its version is a superset and all four components already resolve from one
`hostWorktreePath`. What remains here is the `resolveGitDir` gitfile-pointer
fix that #17896 explicitly deferred, which `worktree-diff-stamp-host-paths.test.ts`
pins.
`getConflictCompatibilityStatus` moves from `existsSync` to async `access`, for
the same reason `detectConflictOperation` did: once these paths are real they
are `\\wsl.localhost\...` shares, and a sync probe per asymmetric conflict
blocks the Electron main thread for a 9p round trip on every status poll.
Per-platform delta:
- native Windows, no WSL: no behavioral change. Nothing here starts with a
single `/`, so no path is translated. An absolute pointer is now returned
verbatim rather than separator-normalized; every consumer re-joins or
normalizes it before use.
- macOS/Linux: no change. Guest-pointer translation is gated to win32, and a
caller-named distro is ignored off Windows.
- Windows + WSL: drvfs pointers and drvfs-spelled worktrees now resolve to their
drive spelling instead of `C:\mnt\...`; a non-drvfs guest path resolves
through the named distro's UNC share, or stays verbatim (ENOENT -> existing
fail-safe) when none is named.
- SSH/relay: none. Those paths return before any of this via the provider
branch; `src/relay/git-handler-status-ops.ts` keeps its own resolveGitDir.
- folder workspaces, GitLab: none. Neither is on these code paths.
|
||
|
|
899304d515 |
Increase artifact content size limit from 5 MiB to 10 MiB (#17910)
Doubles the maximum UTF-8 bytes accepted for manually shared artifacts, enabling users to share larger content while maintaining recovery and transport constraints. |
||
|
|
2b6c14d4b5 |
Add startup delivery diagnostics and success announcements (#17814)
Terminal sessions now report startup command delivery details (whether written, presence, length, and delivery method) without logging the command text—preventing credential leakage and distinguishing missing commands from lost ones in diagnostics. Setup scripts now announce completion on both POSIX and Windows before executing the startup command, so healthy setups don't appear stuck in the UI with "Waiting for setup..." as the last visible line. Diagnostics failures are caught and ignored so they never break session creation. |
||
|
|
1603810dde |
perf(worktree): make head-identity refresh incremental (#17843)
* perf(worktree): make head-identity refresh incremental Head-identity refresh re-read `gitdir` + `HEAD` + a loose ref for every linked worktree on every watcher burst. On a 973-worktree checkout that is ~2,800 metadata reads (~1.0s of main-process fs I/O) per event, and the debounced pipeline fires on every commit in any worktree — so fleet-wide agent activity degenerated into a continuous scan loop. Watcher events already name the admin dir that changed. Classify each event into a head-identity scope, memoize per-entry identities, and re-read only the scoped entries. Refs resolved during a pass are replayed onto cached entries that share the same branch, so `git worktree add --force` siblings stay current without extra reads. Invalidation stays conservative: an absent scope (watcher failure, event overflow, cold start) means a full re-read, `packed-refs` writes invalidate every entry, misses are never memoized, and one refresh per minute is promoted back to a full re-read to bound the window where a ref moves with no event under any admin dir. Measured on the reported 989-entry checkout (macOS/APFS): one-worktree commit 2,816 -> 2 file reads, 61ms -> 0.5ms p50 with an identical page cache; a 20-worktree debounce burst costs 57 reads / 9.7ms; an external `git worktree add`/`remove` costs one readdir / 1.0ms. Refs #17828 * fix(worktree): harden incremental head-identity invalidation Two holes found in self-review: - An admin entry name removed and immediately reused inside one debounce window coalesced into a listing-only scope, so the reused entry kept serving the removed worktree's cached head. Name the entry alongside the listing on every `worktrees/<name>` create/delete. - A non-ENOENT `readdir` failure on `worktrees/` collapsed the memo to the primary row, which then re-emitted every identity on recovery. Mirror worktree-git-common-polling: only a genuinely absent dir means empty; any other error keeps the previous listing. * fix(worktree): let empty-scope bursts still take the head re-baseline Adversarial review found the 60s full-rebaseline promotion was unreachable whenever the triggering burst had an empty head-identity scope: the skip guarded on the raw caller scope and returned before `resolveScope` ran, so `lastFullReadAtMs` was never re-evaluated. A repo whose only churn is `git worktree lock`/`unlock` or a sparse toggle — Orca's own prepared-checkout flow locks and unlocks on every create — could starve the promotion forever and hold a stale head indefinitely. Resolve the scope first and skip on the effective scope. Also stop deferring an add/remove that arrived while the `worktrees/` listing was transiently unreadable: forget the memoized listing so the next refresh re-enumerates whatever its scope, instead of waiting for another listing event. Both fixes carry a test verified to fail without them. * fix(worktree): return head-read completeness instead of sniffing the memo Adversarial review round two. Six fixes, each with a test verified to fail without it. - `readGitCommonHeadIdentities` now returns `{ identities, listingComplete }`. The refresh layer was inferring "enumeration failed" from `cache.entryNames === null`, a reader-owned field whose null also means "cold start" — fragile in production and impossible to express in a mock. - A read discarded by teardown, or one that could not enumerate `worktrees/`, no longer arms the 60s freshness clock. - A queued refresh whose re-run met a destroyed window (macOS recreates the window while the watch lives on) was cleared and dropped. It now stays armed and is folded into the next request. - An incomplete listing carries forward the baseline rows it could not observe, so recovery does not report every linked worktree as changed. - The baseline advances after notifying, so a send into destroyed chrome leaves the move to be retried instead of diffing it away. - A scope naming an entry the memoized listing does not know now forces a re-enumeration instead of resolving to zero work — this removes an unstated dependency on `diffGitCommon` emitting a dir-level create for new entries. - Overflow states FULL at its construction site rather than relying on a downstream `?? FULL` for an absent field. Also documents the load-bearing invariant behind the empty-scope skip (an empty scope only reaches the refresh from a structural burst, which forces `emit: false` and is always paired with a catalog notification for every repo on the watch), and strengthens two tests that could not distinguish the behaviour they claimed. * fix(worktree): bound head-identity staleness with a one-shot catch-up The previous re-baseline was opportunistic: it rode the next refresh, so a ref that moves with no watched write (`git update-ref refs/heads/x` from a sibling worktree) stayed stale until an event happened to arrive after the interval. Pre-PR the very next event anywhere in the repo corrected it, so this was a real narrowing of correctness, not just a pre-existing gap. Arm a one-shot, unref'd timer when a SCOPED pass completes, firing one full re-baseline an interval after the last full read, then disarming. A full pass disarms instead of arming, so it never becomes a background poll, and the timer only exists after an event — an idle repo still schedules nothing and reads nothing. Cost is O(1) timer per active repo and at most one full read per interval: the same operation the old code ran per event, 60x rarer. This also converts "stale until some later event" into "stale at most one interval, period", which is what bounds the blast radius of any invalidation bug in the scoping itself. Cleared on watch disposal. Three tests, each verified to fail without its fix: the catch-up runs with no further events; a quiet repo issues no background reads and the timer disarms after firing; disposal stops it. * fix(worktree): treat an unreadable head as unknown, not absent Reported independently by two PR reviewers. `readTrimmedFile` collapsed every errno to `null`, so an EIO/EACCES/ENFILE on a `gitdir`, `HEAD`, loose ref, or `packed-refs` read was indistinguishable from the file being absent — and the caller deletes the cached identity on `null`. Same conflation AGENTS.md forbids for the SSH verdict vocabulary: loss of contact is not evidence of absence. Reads now report three outcomes, and an unknown: - keeps the entry's last verified identity instead of evicting it, - is never replayed onto siblings sharing the branch as "this ref is gone", - marks the entry unverified so the very next pass re-reads it whatever its scope, and - reports the pass incomplete, so it cannot arm the freshness clock. The reviewers' stated consequence — that an evicted entry stays evicted until the next full pass — did not hold, because `!cache.entries.has(name)` already forced a re-read. The real cost was that one EMFILE evicted every entry it touched and the next pass re-read all of them, which is exactly the full scan this PR exists to remove, plus a spurious re-publish of every row. Renames `listingComplete` to `complete`: it now covers entry reads too. |
||
|
|
dff2ff0ec3 |
fix(git): read the diff working tree and stamp through the host path spelling (#17896)
Git can execute inside a WSL distro against a raw Linux worktree path while Node, on the Windows side, reads the same files back through Win32. `path.join( '/home/me/repo/feature', 'src/file.ts')` on win32 produces the drive-relative `\home\me\repo\feature\src\file.ts`, which resolves against whatever the current drive happens to be and almost always ENOENTs. The same mis-spelling hits the drvfs form, where `/mnt/c/repo` should read as `C:\repo`. Two consequences, both on the Node side only (git already works, because it gets the Linux path as its cwd and resolves it inside the distro): - getDiff's unstaged working-tree read missed, `readWorkingTreeFile` mapped ENOENT to `exists: false`, and an existing file rendered as DELETED in the diff view. - `readWorktreeDiffStamp` could not find `.git`, so the stamp was null, the settled diff cache neither hit nor stored, and every diff respawned `git show` - two `wsl.exe` spawns the cache exists specifically to avoid. Both now spell the worktree directory for the reading host first, via a new `resolveWorktreeHostPath` wrapper around the resolver that landed in #17804. The wrapper exists because `resolveGitMetadataPath` trims: a gitfile payload carries a trailing newline, but a directory name may legally begin or end with whitespace on POSIX, so the wrapper keeps the caller's spelling whenever the resolver only trimmed it. The stamp's opaque `value` still embeds the caller's original `worktreePath`, so settled-cache identity is byte-identical and no cache key moves. `readWorktreeDiffStamp` was already `Promise<WorktreeDiffStamp | null>` with one caller that treats null as a cache miss, so no new nullability enters the type system and the resolver's never-null-for-a-non-empty-pointer contract is untouched. The only unspellable input is an empty worktree path, handled locally as "not provably unchanged" in the stamp and as a read *failure* (not a proven deletion) in file-diff. What changes for users | Platform | Delta | |---|---| | macOS | No change. An absolute POSIX path is returned verbatim, including one whose directory name carries leading or trailing whitespace. | | Linux | No change. Same reason. | | Native Windows (no WSL) | No change. A `C:\...` or `\\server\share\...` path is already absolute for win32 and passes through verbatim. | | Windows + WSL, UNC worktree path (`\\wsl.localhost\Ubuntu\...`) | No change. Already absolute for win32; passes through verbatim. This is today's common case. | | Windows + WSL, drvfs worktree path (`/mnt/c/repo`) | Fixed. Reads as `C:\repo` instead of the drive-relative `\mnt\c\repo`. Needs no distro name. | | Windows + WSL, Linux worktree path with a named distro (`/home/me/repo`) | Fixed. Reads as `\\wsl.localhost\Ubuntu\home\me\repo`. The deleted-file misrender goes away and the diff cache starts hitting. | | Windows, POSIX path, no distro and not a drvfs mount | No change. Passes through verbatim, same ENOENT, same existing fallback. | | SSH | No change. `runtime-git-diff-commands.ts` and the `git:diff` IPC both route to `provider.getDiff` for a connection, so this local code is never reached. | | Relay / remote | No change. No RPC param, wire field, stream opcode, or published content is touched; the relay host runs the same local code and gets the same fix. | | Folder workspace (non-git) | No change. `.git` is absent either way, `resolveGitDir` returns the same fallback, and the stamp stays null exactly as today. | | GitLab / other providers | Not applicable. No provider-specific or review code is touched. | What this does NOT do - It does not fix `resolveGitDir` itself. For a drvfs repo whose worktree Orca already spells `C:\repo\feature`, the gitfile payload `gitdir: /mnt/c/repo/.git/ worktrees/feature` is still mis-resolved by `path.resolve` to `C:\mnt\c\repo\.git\...`, so the stamp still returns null in that shape. Separate change, separate PR; this one neither fixes nor regresses it. - It does not touch submodule path resolution. `resolveSubmoduleWorktreePath` is the path-escape guard and has a near-identical twin in the relay; changing it without escape tests on both is out of scope. - It does not change `readHeadComponent`'s `commondir` resolution. The relative `../..` git actually writes takes the identical `path.resolve` branch, and an absolute POSIX `commondir` under a WSL UNC `gitDir` already resolves correctly because the UNC root is `\\wsl.localhost\<distro>\`. - It does not reorder drvfs-before-UNC inside the shared resolver. That changes the identity of returned strings and needs a real Windows+WSL box. - It does not add any Git command, option, or version dependency. Costs and residual risk - One extra pure function call per diff read. No I/O added or removed on the unaffected paths. - Translation still trims. `resolveWorktreeHostPath` preserves whitespace only when no translation happened; a guest directory named `/home/me/repo ` loses its trailing space on a Windows reader. Reachable only on win32, where such a name is not addressable anyway, and the previous behavior for that shape was a drive-relative miss. - A relative worktree path (no caller passes one) is now resolved against the process cwd instead of joined relative to it. Same file in every case except a relative name that itself ends in whitespace. - `UNSPELLABLE_WORKING_TREE_READ`'s `exists`/`failed` fields are correct but not observable today: the stamp is null for the same input, so nothing can be cached and `reusable` cannot be read back. They are there so the branch stays right if `loadDiff` ever gains a second caller. The test pins the observable part - that no read lands on a cwd-relative path. - Every test here mocks `node:fs/promises` and spoofs `process.platform`. They prove which path string reaches `stat`/`readFile`, which is the right assertion, but none of this has executed against a real 9p mount on a Windows+WSL box and this repo's CI has no such runner. - Honest framing of the trigger: I could not demonstrate a mainline path that hands `getDiff` an untranslated POSIX worktree path on Windows today - `translateWslOutputPaths` UNC-translates worktree paths whenever a distro is known, `getWslHome` returns the UNC spelling, and `resolveWslRepoWorktreeBasePath` normalizes a configured Linux base. The drvfs case is the most plausible live one. Treat this as defense-in-depth that is a strict no-op on every configuration above except the two marked Fixed. Verification - `npx vitest run src/main/git src/shared/git-metadata-path.test.ts` -> 196 files / 2241 tests passed, 2 files and 5 tests skipped. One failure, `git-admission-storm-measurement.test.ts > reports bounded-concurrency before and after measurements` (ENOENT scandir on its own temp state dir), is pre-existing and environmental: it fails identically in isolation and spawns real git children without touching any changed module. - `npx vitest run src/main/git/status-diff-settled-cache.test.ts` -> 21/21 (16 pre-existing, 5 new). `npx vitest run src/shared/git-metadata-path.test.ts` -> 25/25 (19 pre-existing, 6 new cases across 3 new tests). - `npx oxfmt --write` then `npx oxlint` on all five changed files -> clean. Mutation checks - all eight production substitutions were reverted one at a time and the suite re-run. Each fails at least one test, and no new test survives its own mutation: | Reverted | Failing test | |---|---| | file-diff working-tree read -> `worktreePath` | reads the working tree through the host spelling instead of reporting a deletion; invalidates when the working tree file is edited under the host spelling | | stamp working-tree component -> `worktreePath` | invalidates when the working tree file is edited under the host spelling | | stamp `.gitmodules` stat -> `worktreePath` | invalidates when .gitmodules appears under the host spelling | | stamp `resolveGitDir` -> `worktreePath` | stamps through the host spelling so the second read does not respawn git | | `options` threading at the `readWorktreeDiffStamp` call | stamps through the host spelling...; invalidates when .gitmodules appears... | | wrapper's untrimmed preservation -> return the resolver's value | keeps whitespace that belongs to the directory name (both cases) | | `UNSPELLABLE_WORKING_TREE_READ` -> a cwd-relative `readWorkingTreeFile` | reads nothing relative to the cwd when the worktree path has no host spelling | | stamp's null early return -> `hostWorktreePath ?? worktreePath` | reads nothing relative to the cwd when the worktree path has no host spelling | The settled-cache tests seed the fake filesystem through the platform-bound `path` module rather than `path.win32`, so they assert real behavior on a POSIX CI host as well as on Windows and are not gated on the host platform. Co-authored-by: Neil <79079362+brennanb2025@users.noreply.github.com> |
||
|
|
e4a708da17 |
fix(wsl): read untracked line counts through the host's worktree spelling (#17897)
Untracked line counts, and the untracked share of the branch line total, come
from direct lstat/open calls rather than from git. When git executes inside a
WSL distro the worktree path can be a guest path, which Win32 reads as
drive-relative (`/home/me/repo`) or as a literal `C:\mnt\c\repo`. Every lstat
then fails, countFileAdditions swallows the error into `{}`, the file renders
with no +N, and the branch total silently undercounts it.
Route only those two filesystem reads through resolveWorktreeFilesystemPath,
which translates a guest-rooted path via the shared resolveGitMetadataPath.
Both call sites produce the same string, so the stat-keyed untracked cache
still hits across them. resolveGitMetadataPath itself is not modified, so its
other callers are untouched.
The wrapper translates only when the host is win32 and the path is a guest
spelling: exactly one leading slash, and unchanged by trim(). Each condition
is load-bearing.
- `//wsl.localhost/...` and `//wsl$/...` are UNC spellings that also start with
`/`, and translating one prepends the distro root a second time, so a
worktree that reads fine today would ENOENT on every untracked file. The same
single-leading-slash guard is used, for the same reason, by
resolveWslRepoWorktreeBasePath in src/shared/wsl-paths.ts.
- resolveGitMetadataPath returns `rawPath.trim()`, so a worktree directory name
with leading or trailing whitespace (legal on ext4) would be re-spelt onto a
different directory. Leaving those verbatim keeps them exactly as they read
today.
- Off win32 the resolver is already an identity for these inputs; the platform
check makes the macOS/Linux no-op structural rather than derived.
Per platform:
- Windows + WSL, worktree spelled as a guest path: untracked +N appears and the
branch total includes it.
- Windows + WSL, worktree already spelled `\\wsl.localhost\...`,
`//wsl.localhost/...` or `//wsl$/...`: unchanged, returned verbatim.
- Native Windows: `C:\...` is unchanged. One shape does change: a
`/mnt/<drive>/...` worktree now resolves to `<Drive>:\...` instead of being
passed through to a guaranteed lstat failure. Native Windows does not produce
that spelling, and if it were reached the new result is the correct file.
- macOS / Linux: unchanged; not win32, returned verbatim, whitespace included.
- SSH / relay: unchanged. Remote status runs in the relay, which builds its own
branch-total input and does not pass filesystemWorktreePath.
- Folder workspaces / GitLab: unaffected, no workspace-kind or provider
behavior is touched.
No fail-closed degradation: attachLineStats still returns
`stagedStats !== null && unstagedStats !== null`, and createBranchLineTotalInput
still has no early return. The wrapper returns `string`, never null, so an
unmappable worktree keeps its old spelling and its old (missing) untracked
counts rather than dropping the staged/unstaged counts as well.
The `filesystemWorktreePath` field on computeGitBranchLineTotal is optional and
does not touch the lease key, so the coalescing/cooldown identity is unchanged.
Co-authored-by: Neil <neil@orca.local>
|
||
|
|
f2db24bca0 |
fix(worktree): reclaim a prepared checkout whose discard failed in-process (#17899)
Speculative create-preparation evicts entries past the 3-entry limit and the 5-minute TTL, and both paths swallowed a failed `discardPreparedWorktree`. The only other reclaim path, `cleanupStalePreparations`, skips any preparation whose lock-reason pid is still alive, so a discard that failed inside the running app stranded its scratch checkout and its locked worktree registration until restart. Record the failed discard keyed by host (repo path + WSL distro) and prepared path, and retry it the next time a fresh preparation starts for that host. The retry is kicked off before `listWorktreeGraph` but never awaited, so it runs alongside the stale scan and the `worktree add` instead of sitting in front of the user's create. It is capped at 3 attempts and warns when it gives up. Enrolment is unconditional: `prepareWorktreeCreateCheckout` self-discards on failure, but only best-effort, so a checkout that failed on a busy handle can strand the same registration. |
||
|
|
c31e7b0d9a | docs(main): restore rationale comments lost in the startup and cookie splits |