Skip to content

fix(worktrees): never delete a folder git no longer tracks - #25075

Merged
brennanb2025 merged 34 commits into
mainfrom
brennanb2025/sta-9338-leftover-identity
Oct 14, 2026
Merged

brennanb2025 merged 34 commits into
mainfrom
brennanb2025/sta-9338-leftover-identity

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​573 $\color{#cf222e}{\Huge{\mathbf{−}}}$​427 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​146
Prod 19 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​267 $\color{#cf222e}{\Huge{\mathbf{−}}}$​441 $\color{#cf222e}{\Huge{\mathbf{−}}}$​174

The problem

When Orca deletes a worktree, git can fail partway through, for example on a locked file or a root-owned file a Docker container left behind. git then stops tracking the worktree, but most of its folder stays on disk. Since #23952, Orca keeps that half-deleted worktree listed as "Delete failed" so you can press Delete again, and since #23837 a delete that was still running when Orca quit is finished automatically the next time Orca starts.

Both of those later deletes removed the leftover folder with Orca's own file deletion, and so did an older cleanup for Orca-created folders git no longer tracks (#6657). The only check was "git doesn't track it and there's no .git in it". An ordinary folder you create yourself passes that check. So if you removed the leftover by hand and made a new folder with the same name, pressing Delete on the old row, deleting that workspace from the CLI or mobile, or (for an interrupted delete) simply restarting Orca, permanently deleted your new folder and everything in it. The restart case needed no click. This was found by the daily release scan (STA-9338) and blocks promoting the current daily build to stable. No stable release contains #23837 or #23952.

What changes for you

  • Orca never deletes a folder that git no longer tracks as a worktree. If a delete fails partway, the "Delete failed" row stays with git's error. Pressing Delete again while the folder is still there deletes nothing and says: " is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace." Once you remove the folder, the row goes away on its own.
  • When a delete fails partway, the branch you chose to delete is deleted right away if it is fully merged (an unmerged branch is kept, as in a normal delete), and the remote Orca added for that workspace's push target is cleaned up. Removing the folder yourself then leaves nothing behind.
  • If the leftover folder contains another worktree git still tracks, Delete names that worktree instead of asking you to remove the folder.
  • A delete interrupted by quitting is finished at the next start only through git, with the force choice you made. If git refuses (for example because the files it had already deleted look like local changes), nothing is deleted and the worktree stays listed; press Delete and confirm the force prompt.
  • If git had already deleted the checkout's .git before the quit (usual on Windows, where .git is deleted first), git can no longer remove it. Orca now has git drop that stale registration at the next start (keeping every file) and shows the "Delete failed" row with the message above. Before, the worktree came back as a normal row that no Delete could ever remove.
  • Deleting an Orca-created workspace whose folder git no longer tracks and that has no .git now refuses instead of deleting the folder, even when you confirm a force delete. A folder that still has a .git file pointing back at its repository can still be force deleted after you confirm, as before.
  • Normal deletes, and deleting a worktree git still tracks, are unchanged.

How it works

  • Only git deletes a checkout. The retry of a failed row and the startup finish go through git worktree remove, which itself checks that the folder is the registered worktree. Orca's own file deletion runs only in the same operation as a git delete of that checkout (the existing Windows cleanup of what git leaves after it began deleting), and for an orphaned worktree whose .git file points back at this repository.
  • The startup finish honours your choice. It no longer forces by default and no longer rebuilds a missing .git so git could force-remove the folder. A registered checkout whose .git is gone gets git worktree prune (refused while the worktree is locked), then the same handling as any folder git no longer tracks.
  • A failed delete owes only the folder. The moment a delete fails after git dropped the registration, the shared failure path (desktop and runtime) runs the rest of the git side: the branch per your choice (merged only) and the push-target remote. It deletes no files and runs no repo-wide prune; if this cleanup fails it is logged and the "Delete failed" row is unchanged.
  • The older cleanup loses its "Orca created it" branch in both the desktop and runtime delete paths.
  • The failed record ends when its folder is gone, when git tracks a new checkout at that path (Delete then runs the normal delete), or when its repository leaves Orca. Nothing is retried without you pressing Delete.

Why this approach

The bug class is "Orca deletes a folder it can no longer prove is the worktree". The common pattern removes that class instead of guarding it: apps either let git do every delete, or delete leftovers themselves only inside a private folder of their own, and the closest comparable app (one whose worktrees can also live in a folder you choose) keeps a partial-delete leftover and tells you to remove it yourself. Orca's worktrees are ordinary folders you can see and often choose, so it follows the git-only rule.

Alternatives rejected:

  • Record the folder's identity (inode and creation time) and check it before Orca's later deletes. This was this PR's first version. It kept Orca's ability to clean up leftovers, but it stored new state in every delete record, needed several more guards found in review, and went beyond what any comparable app does. Removing the deletion is simpler and leaves nothing to guard.
  • Revert fix(worktrees): a worktree delete git fails partway stays listed and can be retried #23952. It would not remove the risk: the unattended startup finish from fix(worktrees): let git delete removed checkouts so chat sends never wait behind them #23837 and the older cleanup also deleted unproven folders. It would also bring back failed deletes vanishing from Orca.
  • Keep deleting leftovers but confirm with a dialog. The startup finish has no one to ask, and a confirmation names a path, not a folder, so a replaced folder still passes.
  • Rebuild a missing .git at startup so git can finish the delete (what main did). That points git at whatever folder is at the path, which is the same bug through a different door.

Differences from the common pattern

  • Intended: Orca finishes an interrupted delete at the next start, which the common pattern does not do. It is kept because the delete was already accepted, and it now only runs through git with your recorded force choice, so it cannot delete anything git would not. A delete you confirmed with force is still forced at that point, as on main.
  • Intended: Orca keeps a "Delete failed" row for a delete that git left half done; the comparable app also keeps the entry and asks you to remove the folder yourself, which is what Delete now says.

Known weaknesses

  • After a failed delete, Orca no longer cleans up the leftover folder for you, even once the blocking file is fixed; you remove it yourself. This undoes that part of fix(worktrees): a worktree delete git fails partway stays listed and can be retried #23952.
  • On Windows, quitting during a delete now usually ends in a "Delete failed" row asking you to remove the folder, where main finished the delete silently.
  • An interrupted delete without force usually comes back as a normal worktree after restart, because git treats the files it had already deleted as local changes and refuses without force. Press Delete and confirm the force prompt.
  • After Delete refuses, the row's tooltip and the Delete dialog still show git's original error; the "delete the folder yourself" instruction is in the toast, which is titled "Failed to delete workspace".
  • A locked worktree (or a git error at startup) whose .git is already gone still comes back as a normal row that Delete cannot remove; the same happens on main.
  • Brennan's draft Worktree delete: keep Git's safe delete; ask the review host instead of a local squash proof #24269 changes some of the same delete files; whichever lands second needs a rebase.

Linked Issue

STA-9338

Visual Proof

Live Electron QA on macOS (a separate test Mac, an isolated test profile, driven over the DevTools protocol), the PR build at 2c4fa5e (current refusal wording, after a merge of main) against main at 078f8f3, from the same starting state. The later head 1df0592 only merges main again (renamed runtime files, no change to anything on screen) and was not re-run live.

A delete fails partway: both builds show the "Delete failed" row. On this PR the merged branch is already deleted at this point; on main it is still there.
PR: Delete failed row

The bug (STA-9338): the leftover is replaced by your own folder with notes.txt, then Delete is pressed on the old row. On main the row disappears and notes.txt is deleted, with no message:
main: Delete deletes the user's folder
On this PR notes.txt survives (ls shows only notes.txt) and the toast explains what happened; the row stays while the folder is there (also after a reload):
PR: refusal toast with the new sentence
PR: row after reload
After you delete the folder yourself, the row is gone and nothing is left (record, branch):
PR: after the user deleted the folder

Orca's own leftover, blocking file fixed: this PR refuses the same way (by design) and nothing is deleted; an ordinary delete still removes its folder, row and branch.
PR: refusal on Orca's own leftover
PR: ordinary delete still works

Interrupted delete (the app was killed mid-delete, then .git removed as on Windows), then relaunch with no click. main deletes the checkout at startup; this PR keeps all files, drops git's stale registration, and shows the "Delete failed" row whose stored error is the new sentence:
main: deleted at startup
PR: Delete failed row after relaunch

Testing

  • Tests that fail on main and pass here: a failed row's Delete refusing while a folder is at the path (desktop, runtime/CLI, real git), the startup finish deleting nothing in a folder git no longer tracks, no .git rebuild, the recorded non-forced choice, the "Orca created it" cleanup refusing, the startup prune of a checkout whose .git is gone (then Delete after the user removes the folder), the branch deleted when a delete fails, a failed delete not unregistering another worktree whose folder is missing, the push-target cleanup from each delete job, and the nested-worktree message. Each round-of-review fix was checked by removing it and seeing its tests fail.
  • Still works: deleting a worktree git tracks; after the user removes a leftover folder, the row goes away; an orphaned worktree that still has its .git file is force deleted after confirmation; an unmerged branch is kept when a delete fails.
  • Removed tests cover behaviour this PR removes: the leftover retry deleting the folder, the .git rebuild at startup, and the "Orca created it" cleanup helper.
  • 59 affected test files, 1,943 tests pass (2 skipped), including both real-git delete suites (macOS) and the runtime specs. After each merge of main, the removal test files were re-run (latest: 35 files, 1,708 tests, plus the runtime specs).
  • tsc for the node and cli projects, oxlint, oxfmt, the changed-lines code-quality gate and the reliability-gates check pass.
  • Platforms: unit and real-git tests on macOS; live QA on macOS. Windows and Linux were not run by hand; the Windows .git-first case is covered by tests that remove .git before relaunch.

…t started deleting

A failed worktree delete stays listed for a retry, and an interrupted one is
finished at the next start. Both later deletes force-deleted whatever was at
the recorded path, proven only by "git does not list it and it has no .git",
so an ordinary folder the user put at that path since was deleted with its
files, with no click needed in the startup case (STA-9338).

An accepted removal now records the checkout directory's identity (device,
inode, creation time) in its durable record. A retry, a startup finish, and
the in-slot check before the force-delete refuse any folder at the path that
is not that directory. Startup no longer rebuilds a missing .git for a
registered checkout unless the folder is the recorded one. Records without an
identity (older builds, or no creation time) are only finished through Git's
own checks and never force-deleted.
…eation time only

The device number changes when an external volume is attached again, so a
genuine leftover read as a different folder. Windows reports a missing
creation time as a negative number rather than 0, which slipped past the
guard.
…ntity it accepted

A new acceptance site that forgot the identity used to compile and silently
write records no retry or resume could ever finish. The resume tests also
assert the identity was read, so their refusals can't pass as unrecorded.
…ends the workspace

When Orca decided the folder at a failed or interrupted delete's path was not
the one it started deleting, it dropped the removal record but kept the
workspace's metadata. That metadata still says Orca created a checkout there,
and with no record left, the next Delete of the same workspace (a runtime
Delete from mobile, the CLI or a paired client, or a second desktop Delete on a
row not yet refreshed) went to the cleanup that deletes an Orca-created folder
Git no longer lists, and removed the user's folder after all.

Now, when a removal ends without deleting while Git does not list the path (a
listing ends a failed delete, or a retry, resume or first delete fails with no
leftover to show), the host runs a finished delete's bookkeeping: metadata,
terminal history, caches and change events. The folder and the branch are left
alone, and nothing is ended while Git still lists the path.
…e a registered checkout

Reverting that check left every test green: only the unregistered branch's
check had a test where the folder changes during the delete-slot wait.
…ide the delete slot

The check that the folder at the path is still the one Orca accepted ran
before the watcher gate and the delete-slot wait, which can last minutes behind
other large deletes. A folder restored there meanwhile with a copy of the
checkout's .git file passes Git's own validation, so `git worktree remove
--force` deleted it. The resume and retry now repeat the check inside the slot,
right before Git deletes, as the non-Git branch already did; the refusal skips
the Windows recovery that could otherwise finish the delete itself. A normal
first delete is unchanged.
…folder

The handler result is untyped, so the previous catch-all did not typecheck; the
assertion now names the refusal the Delete gets once the workspace has ended.
…told when Orca left the folder in place"

This reverts commit 68d6598.

The shortcut reaches no real client: the CLI and mobile list the workspace before deleting it, which ends the failed delete first, so they still answer not found (the folder is kept either way).
…ed once Git says it still registers the checkout
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review loop status (final head a5fc147)

The problem. Since #23952 a failed worktree delete stays listed as "Delete failed" so you can retry it, and since #23837 a delete interrupted by quitting is finished at the next startup. Both force-deleted whatever folder was at the path, proven only by "git does not list it and it has no .git". A folder you created at that path afterwards was deleted with your files, at startup with no click. Once the failed row was gone, a Delete of that workspace by id (CLI, mobile, a second Delete) reached the same folder through Orca's older cleanup of unregistered leftovers.

What changes for you. A retried or resumed delete only removes the exact folder Orca started deleting; a different folder is left in place with "The folder at … is not the one Orca started deleting, so Orca left it in place." in the existing delete-failure toast. The workspace is then removed from Orca with its folder and branch kept, so no later Delete can reach the folder. A folder Orca cannot read keeps its "Delete failed" row and is refused until it can be read. Normal deletes and retrying a real leftover are unchanged.

Fixed during review (3 rounds plus a readiness pass).

  • A Delete by id after a refusal still deleted the folder through the older leftover cleanup: the workspace now ends when its delete stops without deleting (labeled temporary in the body).
  • A Delete racing the listing's git check, a git check that failed, a settle-time git error, and a restart with a failed record whose folder was gone could each leave a later Delete able to delete the folder: the listing now asks git first and drops and ends in one step, and unknown answers keep the record.
  • The record could leave the disk before the workspace's end was saved; it now waits for that save.
  • A folder that could not be read was treated as a different folder and its genuine row dropped; it now keeps the row and refuses, with its own message.
  • A failed record kept while git could not be asked had no way to end if git still listed the path; the listing now re-checks it.
  • Windows "no creation time" values, device numbers changing on external volumes, and a check that ran minutes before the delete instead of right before it.
  • A shortcut meant to give CLI/mobile Delete the refusal message reached no real client and was reverted.

Deferred (in the PR body's Known weaknesses). No creation time on the filesystem fails closed (the leftover stays with no row); FAT/exFAT/synthesized-id shares unverified; CLI and mobile Delete of a replaced row say "no workspace matched" (folder kept); the refusal shows under a "Failed to delete" title with a "View" button; the workspace-removed plugin event fires with the folder kept; a crash inside one settings save can drop the record early; small code-placement nits.

Open decision for @brennanb2025. The bug class lives in Orca's older cleanup of unregistered leftovers, which deletes a folder because Orca created a workspace there and it has no .git. The real fix is to record the folder identity when a worktree is created and have that cleanup require it, which changes cleanup for existing worktrees. This PR adds only the minimum guard beside it.

Verified. Live on macOS with real git in an isolated profile: on main, Delete on the failed row and the startup finish both deleted notes.txt; on this PR it survived the row's Delete, a reload, the desktop delete call with the old id, CLI deletes by id and by path, and a relaunch after a killed delete, and the positive control still deleted the real leftover (final head re-run for row, refusal and control; screenshots in the body). Every round's fixes had proof tests that failed before and pass after; affected test files pass (1,449 passed, 1 skipped); node and cli typechecks clean except one pre-existing main error; readiness checklist (crash, performance, correctness, remote, security, compatibility, cross-platform, organization) found no P0, P1 or P2; CI green on the final head.

Not verified. Windows, Linux and WSL were not run live (creation-time behavior traced in source; the check fails closed where it is missing); FAT/exFAT; mobile UI (no mobile code changed).

@brennanb2025
brennanb2025 marked this pull request as ready for review October 4, 2026 05:30
@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c1fb3438-257d-41dc-9785-f01a8d7b5d41
📥 Commits

Reviewing files that changed from the base of the PR and between 487aa13 and 0cc465e.

📒 Files selected for processing: 6
  • src/main/ipc/worktrees-failed-removal.test.ts
  • src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts
  • src/main/runtime/runtime-failed-local-worktree-removal.test.ts
  • src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts
  • src/main/worktree-failed-removal.test.ts
  • src/main/worktree-removal-leftover.ts
🚧 Files skipped from review as they are similar to previous changes: 6
  • src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts
  • src/main/worktree-removal-leftover.ts
  • src/main/ipc/worktrees-failed-removal.test.ts
  • src/main/worktree-failed-removal.test.ts
  • src/main/runtime/runtime-failed-local-worktree-removal.test.ts
  • src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

📝 Walkthrough

Worktree removal now refuses to delete unregistered checkout folders that remain on disk, including when forced. Failed removals retain records and listing errors while the folder exists, and can finish after it is removed. Git-side cleanup can remove eligible branches and unused push-target remotes after partial failures. Interrupted-removal recovery uses the recorded force setting, validates checkout identity, and no longer restores missing .git files. Orphan-directory checks now cover local and SSH removal paths.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 0cc46

The change makes worktree deletion safer by refusing to remove folders Git no longer tracks. No concrete merge-blocking risk was identified in the supplied context.

Security architecture risk: 🔵 Low · up to 487aa

The change reduces accidental deletion risk by refusing cleanup while an unregistered folder remains and by honoring the original deletion choices during recovery. No introduced or worsened security concern was established in the inspected paths, but the assessment does not cover every removal route or deployment condition.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • observed — The affected authority includes checkout filesystem deletion, branch refs, and push-target remote configuration in the resolved repository. The changed failed-removal continuation remains restricted to local routes; remote routes do not enter that continuation.

Security Findings and Attack Paths

  • inferred — A registered checkout recreated at the same path with the same branch and head can satisfy interrupted recovery's identity test. Exploiting this requires control of the local checkout or Git registration and an already pending removal. The identity test and direct startup resumption existed at the base revision; this PR does not introduce that exposure and reduces forced-deletion authority.

Trust Boundaries and Controls

  • observed — The changed entrypoint continues to resolve a selector and optional required host before removal, rejects ambiguous repository ownership, and uses one resolved host route for listing and deletion. Its retry change does not replace those checks with a caller-supplied filesystem path.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 25 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely describes the primary change: preventing Orca from deleting folders that Git no longer tracks.
Description check Passed The description is comprehensive and covers the problem, user-visible behavior, implementation approach, alternatives, linked issue STA-9338, visual proof, testing, platform coverage, and known weakne…
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
⚔️ Resolve merge conflicts: 💡
  • Resolve merge conflict in branch brennanb2025/sta-9338-leftover-identity
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one residual window worth a look, inline.

Reviewed changes

  • Checkout identity — new worktree-checkout-identity.ts records the accepted directory as { ino, birthtimeNs } (directory only, positive creation time only, no device number) and matches it as same / different / absent / unrecorded / unreadable, failing closed where the filesystem keeps no creation time.
  • Identity on the durable record — checkoutIdentity persists on WorktreeRemovalRecord and is captured at acceptance in both the desktop IPC and runtime local removal entry points; parseCheckoutIdentity drops a legacy dev field and treats malformed values as unrecorded.
  • In-slot refusal — assertCheckoutBeforeDelete runs inside the delete slot before git worktree remove; finishRuntimeLocalWorktreeRemoval rethrows the refusal so the Windows recovery can no longer delete the path in Git's place.
  • Resume / retry — runtime-interrupted-local-worktree-removal.ts re-derives state from Git and disk, refuses a different or unreadable folder, and lets only Git's own delete finish an older record without an identity.
  • Failed-delete classification and workspace end — checkoutLeftByFailedRemoval plus endUnfinishedWorktreeRemoval end the workspace (keeping folder and branch) when the folder is gone or replaced, closing the Delete-by-id route into Orca's unregistered-leftover cleanup.

I read the complete diff and ran the focused suites on Linux (worktree-checkout-identity, worktree-failed-removal, worktree-background-removal*, ipc/worktrees-orphan-directory-cleanup, runtime/runtime-interrupted-local-worktree-removal) — all pass. The identity check, the in-slot re-check, and the listing/settle state machine are carefully constructed; I could not find a non-error interleaving that violates the workspace-end invariant. The one finding below is a residual window on the host-failure path.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread src/main/worktree-removal-table.ts Outdated
@brennanb2025
brennanb2025 marked this pull request as draft October 4, 2026 20:28
…and that Git no longer tracks

Deleting an Orca-created workspace whose folder Git no longer tracked and that had no `.git` force-
deleted the whole folder once the user confirmed force. "Orca made a workspace here once" and "there
is no .git" do not prove the folder still holds that checkout: an ordinary folder the user put at
the path was deleted.

Such a folder now gets the existing "Refusing to delete unregistered worktree path" answer, forced or
not. Once the user removes the folder, Delete takes the existing missing-path branch and drops the
workspace. An orphaned worktree whose `.git` file points back at this repo is still deleted after
the user confirms force, as before.
… Git

A delete that failed partway (Git dropped the registration, files left) stays listed as "Delete
failed", and a delete a quit interrupted is finished at the next launch. Both later deletes removed
the folder with Orca's own file removal, proven only by "Git does not list it and there is no .git".
An ordinary folder the user put at that path was deleted, at startup without any click.

Orca now deletes a checkout only through Git, plus the existing cleanup right after a Git delete that
succeeded in the same operation:

- Delete on a failed row: if Git registers a checkout at the path, the normal delete runs. If the
  folder is still there and Git does not register it, Delete refuses with "Git no longer tracks
  <path>, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the
  folder is gone." and keeps the row. With the folder gone, Delete prunes Git's stale entry, deletes
  the branch as recorded and drops the workspace.
- Startup finish of an interrupted delete: a checkout Git still registers is removed with
  `git worktree remove` and the user's recorded force choice, no longer always forced. If Git refuses
  or fails, the row shows Git's error. The step that rewrote a missing `.git` so Git could force the
  delete is gone. A folder Git does not register becomes a failed row and is not deleted; with the
  folder gone, the rest of the delete finishes.

Records written by earlier daily builds still load; fields this version does not know are ignored.
… and say the listing drops the workspace

A startup finish that Git refuses for a checkout Git still registers now clears its record and
leaves Git's normal row, exactly as an in-session failure does on main; the resumed-only failed
record and the listing that put Git's error on Git's own row are gone, and the listing is main's
again. An unregistered folder that still exists is still a failed row, and nothing is deleted.

The refusal now reads: "Git no longer tracks <path>, so Orca won't delete it. Remove the folder
yourself, and Orca will drop this workspace from the list." The listing drops a failed record once
its folder is gone, as on main.
@brennanb2025 brennanb2025 changed the title fix(worktrees): a retried or resumed delete only removes the folder it started deleting fix(worktrees): never delete a folder git no longer tracks Oct 4, 2026
…e when it fails

A delete Git fails partway keeps a "Delete failed" row for the folder Git left. The row's record
also carried the branch delete and the push-target remote cleanup for later, but the listing drops
the record once the user removes the folder, so both were silently left behind. Now, once Git no
longer registers the checkout, the failure runs that Git side right away (merged branch only,
stale admin entry, push-target remote), in the one settle path desktop and runtime share. It
deletes no files, and a failure of this bookkeeping is logged without touching the failed row.
…t Git already deleted

A quit mid-delete can leave a checkout Git still registers but whose .git link Git already
deleted (Git deletes it first on Windows). No `git worktree remove` can validate it, so the startup
finish failed, the record was cleared, and the workspace came back as a normal row no Delete could
ever remove. The startup finish now has Git prune that registration (refused while locked; every
file kept) and then treats the folder as one Git no longer registers: the "Delete failed" row with
the remove-it-yourself message, ending once the user removes the folder. Adds the non-forced
partial-delete case: Git refuses, the files stay, and the row returns as before.
…s folder before asking the user to remove it

The refusal for a folder Git no longer tracks tells the user to remove the folder. When that folder
holds another worktree Git still registers, doing so would delete that worktree's files too. Delete
on the failed row and the startup finish now run the existing nested-worktree check first, so its
message ("contains another registered worktree: <path>") wins, as it did before this branch.
The Git side of a failed delete started with a repo-wide `git worktree prune`. It did nothing for
the failed delete, whose admin entry Git had already dropped, but it unregistered any other
worktree whose folder was missing at that moment (an unmounted drive), so Orca dropped that
workspace too. The failure now runs only the branch step; the prune stays where it was on main,
in the folder-gone finish and at startup.
… for a failed delete

A failed delete removes the push-target remote Orca added through the hook each job creator passes: desktop Delete, runtime Delete, and the startup finish. Nothing tested that wiring; these tests fail when any creator drops it.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review loop result (design v2, head 487aa13)

The problem. A worktree delete that git fails partway (#23952) or that a quit interrupts (#23837) was later finished by Orca's own file deletion, proven only by "git doesn't track it and it has no .git". An ordinary folder you put at that path passed the check and was deleted, with no click at all in the startup case. The older "Orca created it" cleanup (#6657) had the same weak proof. Release-blocking (STA-9338).

What changes for you. Orca never deletes a folder git no longer tracks. Delete on a "Delete failed" row whose folder is still there deletes nothing and tells you to remove the folder yourself; the row then goes away on its own. When a delete fails, the branch you chose to delete (if merged) and Orca's push-target remote are cleaned up right away, so nothing is left once you remove the folder. A delete interrupted after git had already deleted the checkout's .git (usual on Windows) now comes back as a "Delete failed" row instead of a normal row no Delete could remove. Interrupted deletes finish only through git with your recorded force choice.

Fixed in this loop (full review of the v2 rework, an independent architecture challenge, a narrow re-check, and the readiness checklist):

  • P1 (4000789): an interrupted delete whose .git git had already removed came back as a normal row that every Delete failed on ("validation failed", even with force). Startup now has git prune that stale registration (refused while locked), keeps every file, and shows the "Delete failed" row.
  • P2 (75d54ef): after a failed delete, removing the folder yourself silently left the branch you asked to delete and the push-target remote. The failure path now runs that git side immediately (merged branch only; no files deleted; failures only logged).
  • P3 (d7d6128): if the leftover contains another worktree git still tracks, Delete names it instead of asking you to remove the folder (which would delete that worktree too).
  • Loop-caused, fixed (7ffe5a3, 487aa13): the failure path's repo-wide git worktree prune could unregister another worktree whose folder was momentarily missing (unmounted drive); removed. Added tests that each delete job wires the push-target cleanup.

Unexpected user-facing behaviour for your call (all P3, none blocks the fix):

  • After Delete refuses, the row's tooltip and the Delete dialog still show git's original error ("Operation not permitted"); the "remove the folder yourself" instruction is only in the toast. The startup path stores the new sentence, so the two routes read differently. Fix would be to rewrite the stored error on refusal.
  • The refusal toast is titled "Failed to delete workspace " with an error icon, and the dialog on a failed row still says "...and delete its workspace folder" although the press will refuse. Both are existing copy.
  • On Windows, quitting during a delete now usually ends in a "Delete failed / remove the folder yourself" row, where main finished it silently by rebuilding .git. This follows the approved "never rebuild .git" rule.
  • An interrupted non-forced delete usually comes back half-deleted as a normal row (git sees the deleted files as changes); Delete + the force prompt works. Accepted earlier and listed in Known weaknesses.

Deferred (P3, in the ledger): a locked worktree whose .git is gone still cannot be deleted inside Orca (same on main); prune-verify failure wording; the former "Orca created it" state gets the raw "Refusing to delete unregistered worktree path" text; a rare race where a toast offers Force Delete that then refuses; the failure toast waits for the branch step (up to a git fetch for an unmerged branch, same as a successful delete); failed records written by earlier daily builds never ran the new branch step; two of the new tests run only on macOS (PR CI has no macOS test job).

Not caused by this PR: the Windows recovery and the .git-file orphan cleanup are pre-existing file deletes (safe: same operation as a git delete, or a .git file naming this repo); the stale "Operation not permitted" toast lingers ~250 ms after a successful retry on main too; src/main/claude/claude-child-work-evidence-retention.test.ts fails tsc on main.

Verified. Real git 2.50.1 probes (remove on a checkout missing .git exits 128 even with force; prune keeps the folder and frees the branch; branch -d needs no prune on the failure path). 59 affected test files, 1,943 tests pass; each fix's tests fail with the fix removed; tsc node + cli pass through the gate; oxlint/oxfmt, code-quality and reliability gates pass; CI green on 487aa13. Live Electron QA on the M4Air (non-Claude driver, isolated profile) at d7d6128 vs main 752871b: main deleted the user's notes.txt on Delete and an interrupted checkout at startup; this PR kept notes.txt, refused with the message, kept the row until the folder was removed, deleted the merged branch at failure, and turned the .git-missing startup case into a "Delete failed" row with all files kept. Screenshots are in the PR body.

Not verified. Windows and Linux by hand (the Windows .git-first case is covered only by tests that remove .git); SSH/WSL are unaffected by construction (these paths run only for local, non-WSL background deletes) and were not exercised live; mobile was not exercised (no mobile surface changed); whether a toast appears at startup in the .git-missing case (none was on screen a minute after relaunch). The last two commits (prune removal, tests) were not re-run live; they change nothing on screen.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 5, 2026 00:04

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This PR removes the class of bug where an Orca-owned delete recursively removed a folder at a path Git no longer registers — the STA-9338 data-loss finding. I read the complete 29-file diff; the branch was reworked mid-flight from an inode/creation-time identity design to the simpler git-only rule reviewed here.

  • Git-only checkout delete. finishUnregisteredWorktreeRemoval (src/main/git/worktree-removal.ts) now asserts the checkout is gone, prunes, and deletes only the branch; the new deleteBranchOfUnregisteredWorktree deletes no files. Orca's own recursive cleanup is removed from the desktop remove-unregistered-worktree.ts and runtime runtime-unregistered-worktree-removal.ts unregistered paths.
  • Dead code removed. canCleanupUnregisteredOrcaLeftoverDirectory, isLocalGitRepository, isLocalRuntimeGitRepository, gitStatusErrorMeansNotRepository, isDirectoryStat, and src/main/git/worktree-git-file-restore.ts are deleted with no dangling references.
  • Refusal guard. New assertUnregisteredCheckoutGone/unregisteredFolderRefusal refuse while anything is at an unregistered path, naming a nested registered worktree first; retryFailedRemovalUnlessRegistered (now async, moved to worktree-removal-leftover.ts) drops a failed record only when Git registers the path again and finishes the removal only once the folder is gone. Both call sites updated.
  • Startup finish honors the recorded choice. force: record.force replaces the hardcoded force, and a registered checkout whose .git Git already deleted is pruned (files kept) then refused rather than having .git rebuilt.
  • Failure path owns the Git side. New failureLeftByGit runs the recorded merged-branch delete and push-target remote cleanup when a background delete fails with the checkout left unregistered, on both desktop and runtime jobs, deliberately without a repo-wide prune.
  • Tests cover the desktop/runtime/real-Git refusals, the branch and push-target cleanup on failure, nested-worktree naming, the recorded non-forced choice, and no sibling worktree being pruned when a delete fails.

I traced the remaining recursive-delete sites in src/main/: each is gated by the .git orphan backlink proof (worktree-orphan-gitdir-proof.ts) or is the same operation as a Git delete (Windows recovery, removeCheckoutLeftByGit), so no unproven folder delete remains. removeOrphanOrFolderWorktree performs no filesystem deletion at all.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…-leftover-identity

# Conflicts:
#	src/main/ipc/worktrees/removal/execute-worktree-removal.ts
#	src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts
#	src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts
#	src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts
#	src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts
#	src/main/runtime/runtime-unregistered-worktree-removal.ts
…-leftover-identity

# Conflicts:
#	src/main/runtime/workspaces/workspace-removal-target.ts
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Re-review and re-QA after the new refusal wording and two merges of main (head 1df0592)

The problem (STA-9338): when a worktree delete failed partway, Orca's later "finish the delete" steps (pressing Delete on the "Delete failed" row, CLI or mobile delete, and the automatic finish at the next start) deleted whatever folder was at the path, because the only check was "git doesn't track it and there's no .git". A folder you made yourself at that path passed, so it was deleted, at startup without any click.

What changes for you: Orca never deletes a folder git no longer tracks as a worktree. Delete on a failed row deletes nothing while a folder is there and says: " is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace." Once you delete the folder, the row goes away and nothing is left behind.

What this round reviewed: only what changed since the loop converged at 487aa13: the new wording (0cc465e) and the merges of main. Three fresh reviewers covered three areas: merge correctness, interaction with main's new delete code, and whether the new sentence is accurate everywhere it appears.

  • Merge correctness: no findings. Each of the PR's changes carried over unchanged in intent, and main's nested-worktree approval, authorization-root refresh and hook-status cleanup all still run, including on the folder-gone and startup-finish paths.
  • The new sentence is true in every state that shows it. A nested worktree still gets its own message first, and nothing in the code matches the old text.

Fixed this round: the branch became conflicting after main renamed the runtime delete files and added an abstract retryFailedLocalRemoval(): boolean. I merged main once more (1df0592), kept main's file layout, moved the PR's async retryFailedRemoval onto it, and updated one main test to the async call. CI's earlier red shard (openMobileFile arity) was main's own break, which main fixed in #28612.

Deferred (P3, not caused by this PR unless marked):

  • In the rare case where a failed row's leftover folder holds another worktree git still tracks, the toast now offers "Delete with nested worktrees…", which always refuses. main behaves the same.
  • Mobile shows no message for any failed delete; the row simply comes back. main behaves the same.
  • (This PR) "its folder was left on disk" is loose when the folder at the path is one you made yourself rather than Orca's leftover. The wording was a deliberate choice, so I left it.
  • Still open from the last round: after a refusal, the row tooltip and the Delete dialog show git's old error, and the new sentence appears only in the toast. The toast is titled "Failed to delete workspace". The failed-row Delete dialog still says it will delete the folder.

Verified:

  • CI is green on 1df0592 (all checks, including PR checks passed).
  • tsc for the node and cli projects passes.
  • 35 removal test files pass (1,708 tests), plus the runtime specs.
  • Live Electron QA on a separate Mac, PR 2c4fa5e against main 078f8f3, scenarios S1–S4: all pass on this PR.
    • S2 (the bug): your notes.txt survives and the toast shows the new sentence; main deletes notes.txt.
    • S3: the positive control passes; an ordinary delete still removes its folder.
    • S4: the startup case shows the "Delete failed" row with the new sentence, and every file is kept; main deletes the checkout at startup.
    • The 38 screenshots were checked; the key ones are in the PR body.

Not verified:

  • The final merge commit 1df0592 was not re-run live. Against the QA'd head it changes only file locations and types, nothing on screen.
  • Windows and Linux were not run by hand.
  • The local orca-ci-checks bun typecheck step could not run on this machine, a tooling error, but CI's static analysis and typecheck passed.

@brennanb2025
brennanb2025 merged commit 3825530 into main Oct 14, 2026
30 checks passed
@brennanb2025
brennanb2025 deleted the brennanb2025/sta-9338-leftover-identity branch October 14, 2026 18:00
AmethystLiang added a commit that referenced this pull request Oct 14, 2026
* fix(worktrees): never delete a folder git no longer tracks (#25075)

* fix(worktrees): a retried or resumed delete only removes the folder it started deleting

A failed worktree delete stays listed for a retry, and an interrupted one is
finished at the next start. Both later deletes force-deleted whatever was at
the recorded path, proven only by "git does not list it and it has no .git",
so an ordinary folder the user put at that path since was deleted with its
files, with no click needed in the startup case (STA-9338).

An accepted removal now records the checkout directory's identity (device,
inode, creation time) in its durable record. A retry, a startup finish, and
the in-slot check before the force-delete refuse any folder at the path that
is not that directory. Startup no longer rebuilds a missing .git for a
registered checkout unless the folder is the recorded one. Records without an
identity (older builds, or no creation time) are only finished through Git's
own checks and never force-deleted.

* fix(worktrees): identify the accepted checkout by inode and a real creation time only

The device number changes when an external volume is attached again, so a
genuine leftover read as a different folder. Windows reports a missing
creation time as a negative number rather than 0, which slipped past the
guard.

* refactor(worktrees): every background removal states the checkout identity it accepted

A new acceptance site that forgot the identity used to compile and silently
write records no retry or resume could ever finish. The resume tests also
assert the identity was read, so their refusals can't pass as unrecorded.

* fix(worktrees): a delete that leaves the user's folder in place also ends the workspace

When Orca decided the folder at a failed or interrupted delete's path was not
the one it started deleting, it dropped the removal record but kept the
workspace's metadata. That metadata still says Orca created a checkout there,
and with no record left, the next Delete of the same workspace (a runtime
Delete from mobile, the CLI or a paired client, or a second desktop Delete on a
row not yet refreshed) went to the cleanup that deletes an Orca-created folder
Git no longer lists, and removed the user's folder after all.

Now, when a removal ends without deleting while Git does not list the path (a
listing ends a failed delete, or a retry, resume or first delete fails with no
leftover to show), the host runs a finished delete's bookkeeping: metadata,
terminal history, caches and change events. The folder and the branch are left
alone, and nothing is ended while Git still lists the path.

* test(worktrees): cover the in-slot folder check when Git cannot remove a registered checkout

Reverting that check left every test green: only the unregistered branch's
check had a test where the folder changes during the delete-slot wait.

* fix(worktrees): a resumed or retried Git delete checks the folder inside the delete slot

The check that the folder at the path is still the one Orca accepted ran
before the watcher gate and the delete-slot wait, which can last minutes behind
other large deletes. A folder restored there meanwhile with a copy of the
checkout's .git file passes Git's own validation, so `git worktree remove
--force` deleted it. The resume and retry now repeat the check inside the slot,
right before Git deletes, as the non-Git branch already did; the refusal skips
the Windows recovery that could otherwise finish the delete itself. A normal
first delete is unchanged.

* test(worktrees): assert the second desktop Delete refuses the user's folder

The handler result is untyped, so the previous catch-all did not typecheck; the
assertion now names the refusal the Delete gets once the workspace has ended.

* test(worktrees): the desktop Delete test's stand-in host ends the workspace without a cast

* fix(worktrees): a listing asks Git before dropping a failed delete, and ends its workspace in the same step

* fix(worktrees): a failed delete Git cannot be asked about stays failed for the next listing to decide

* test(worktrees): the failed-delete record test states that Git still registers the checkout

* fix(worktrees): a restart keeps a failed delete whose checkout is gone, so the first listing ends its workspace

* fix(worktrees): a folder Orca cannot read keeps its failed delete and is never deleted

* fix(worktrees): a failed delete leaves the disk only after its workspace's end is saved

* fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place

* test(worktrees): the desktop Delete test's stand-in host returns nothing

* fix(worktrees): a Delete of a failed delete's leftover whose .git Orca cannot read says so

* Revert "fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place"

This reverts commit 68d65989def954d1831bfcb4fad673e3b9896f73.

The shortcut reaches no real client: the CLI and mobile list the workspace before deleting it, which ends the failed delete first, so they still answer not found (the folder is kept either way).

* fix(worktrees): a failed delete Git could not be asked about is dropped once Git says it still registers the checkout

* revert to main before the standard-model rework

* fix(worktrees): stop deleting Orca-created folders that have no .git and that Git no longer tracks

Deleting an Orca-created workspace whose folder Git no longer tracked and that had no `.git` force-
deleted the whole folder once the user confirmed force. "Orca made a workspace here once" and "there
is no .git" do not prove the folder still holds that checkout: an ordinary folder the user put at
the path was deleted.

Such a folder now gets the existing "Refusing to delete unregistered worktree path" answer, forced or
not. Once the user removes the folder, Delete takes the existing missing-path branch and drops the
workspace. An orphaned worktree whose `.git` file points back at this repo is still deleted after
the user confirms force, as before.

* fix(worktrees): delete a failed or interrupted workspace only through Git

A delete that failed partway (Git dropped the registration, files left) stays listed as "Delete
failed", and a delete a quit interrupted is finished at the next launch. Both later deletes removed
the folder with Orca's own file removal, proven only by "Git does not list it and there is no .git".
An ordinary folder the user put at that path was deleted, at startup without any click.

Orca now deletes a checkout only through Git, plus the existing cleanup right after a Git delete that
succeeded in the same operation:

- Delete on a failed row: if Git registers a checkout at the path, the normal delete runs. If the
  folder is still there and Git does not register it, Delete refuses with "Git no longer tracks
  <path>, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the
  folder is gone." and keeps the row. With the folder gone, Delete prunes Git's stale entry, deletes
  the branch as recorded and drops the workspace.
- Startup finish of an interrupted delete: a checkout Git still registers is removed with
  `git worktree remove` and the user's recorded force choice, no longer always forced. If Git refuses
  or fails, the row shows Git's error. The step that rewrote a missing `.git` so Git could force the
  delete is gone. A folder Git does not register becomes a failed row and is not deleted; with the
  folder gone, the rest of the delete finishes.

Records written by earlier daily builds still load; fields this version does not know are ignored.

* test(worktrees): read Git's listing before the Delete call in the real-Git failed-delete suite

* docs(worktrees): say when a failed delete's record ends under the Git-only delete

* fix(worktrees): keep main's handling of a startup finish Git refuses, and say the listing drops the workspace

A startup finish that Git refuses for a checkout Git still registers now clears its record and
leaves Git's normal row, exactly as an in-session failure does on main; the resumed-only failed
record and the listing that put Git's error on Git's own row are gone, and the listing is main's
again. An unregistered folder that still exists is still a failed row, and nothing is deleted.

The refusal now reads: "Git no longer tracks <path>, so Orca won't delete it. Remove the folder
yourself, and Orca will drop this workspace from the list." The listing drops a failed record once
its folder is gone, as on main.

* fix(worktrees): finish a failed delete's branch and push-target remote when it fails

A delete Git fails partway keeps a "Delete failed" row for the folder Git left. The row's record
also carried the branch delete and the push-target remote cleanup for later, but the listing drops
the record once the user removes the folder, so both were silently left behind. Now, once Git no
longer registers the checkout, the failure runs that Git side right away (merged branch only,
stale admin entry, push-target remote), in the one settle path desktop and runtime share. It
deletes no files, and a failure of this bookkeeping is logged without touching the failed row.

* fix(worktrees): unregister an interrupted delete's checkout whose .git Git already deleted

A quit mid-delete can leave a checkout Git still registers but whose .git link Git already
deleted (Git deletes it first on Windows). No `git worktree remove` can validate it, so the startup
finish failed, the record was cleared, and the workspace came back as a normal row no Delete could
ever remove. The startup finish now has Git prune that registration (refused while locked; every
file kept) and then treats the folder as one Git no longer registers: the "Delete failed" row with
the remove-it-yourself message, ending once the user removes the folder. Adds the non-forced
partial-delete case: Git refuses, the files stay, and the row returns as before.

* fix(worktrees): name a worktree Git registers inside a failed delete's folder before asking the user to remove it

The refusal for a folder Git no longer tracks tells the user to remove the folder. When that folder
holds another worktree Git still registers, doing so would delete that worktree's files too. Delete
on the failed row and the startup finish now run the existing nested-worktree check first, so its
message ("contains another registered worktree: <path>") wins, as it did before this branch.

* fix(worktrees): don't prune other worktrees when a delete fails

The Git side of a failed delete started with a repo-wide `git worktree prune`. It did nothing for
the failed delete, whose admin entry Git had already dropped, but it unregistered any other
worktree whose folder was missing at that moment (an unmounted drive), so Orca dropped that
workspace too. The failure now runs only the branch step; the prune stays where it was on main,
in the folder-gone finish and at startup.

* test(worktrees): cover the push-target cleanup every delete job wires for a failed delete

A failed delete removes the push-target remote Orca added through the hook each job creator passes: desktop Delete, runtime Delete, and the startup finish. Nothing tested that wiring; these tests fail when any creator drops it.

* fix(worktrees): say the leftover folder was left on disk instead of what Orca won't do

* Shorten worker sub-dispatch instructions (#28565)

* Allow workers to dispatch one level of sub-workers by default

The default nested worker depth was 1, so a coordinator's workers were refused on their first dispatch. Raising it to 2 lets one generation of sub-workers run without changing any settings. The depth constant, the global settings default, and the affected tests now share that value, so they stay in step.

- Set NESTED_WORKER_MAX_DEPTH_DEFAULT and the settings default to 2.
- Pin the cap-1 refusal test to an explicit cap of 1, and point the depth-2 test at the default constant.
- Update the resolver and settings pane tests to expect 2 when unset.

* Keep worker depth at 1 and shorten sub-dispatch instructions

* fix(unread): a pane is read when it is on screen; closing a waiting tab clears its dot (#28637)

* test(unread): characterize today's workspace and pane unread transitions

Pins, against the real store, what every unread trigger does today: the workspace
flag per host row, the three tab/pane marker maps, persistence calls and payloads,
lastActivityAt and sortEpoch bumps, the Dock count and the floating launcher dot.
Events are replayed through one driver file so a later rewiring of who writes the
state changes only the driver, never these expectations.

* refactor(unread): one workspace-attention module owns every unread transition

Every write to the workspace unread flag and the tab/pane/completion markers now
goes through src/renderer/src/store/slices/workspace-attention: store operations
named by the triggering event (bell, agent attention, pane input, viewed-agent
auto-ack, manual mark read/unread) and pure transitions folded into other slices'
set() calls (activation, tab activation, close, sleep, hibernate, retire, rekey,
reconcile, mirror removal, purge, acknowledge). Behaviour, storage, readers,
persistence and wire format are unchanged; the characterization suites pass
without edits to their expectations.

* refactor(unread): review fixes for the workspace-attention module

- Ratchets: moved terminal-tab-attention baseline entries to terminal-tab-appearance; split
  the characterization setup/observables into an exempt fixture so the driver holds only events.
- Removed the test-only writer seam; mocked-store tests assert the event they fire.
- The slice owns the experimental pane gate for completions too; groupAttentionEnabled is gone
  from the attention request and write.
- Collapsed marker removals to removeTabsAttention / removePanesAttention /
  removeCompletionAttention / removeTabMarkers / movePaneAttention; tab activation uses
  omitRecordKeys.
- Mark read/unread is passed in like other store actions; readers of the workspace dot go
  through isWorkspaceUnread.

* feat(unread): on screen means read; the workspace flag follows its markers

While Orca's window is visible and focused, the tabs on screen (active tab
of the active workspace, plus an open floating panel) are read: every tab,
pane and completion marker of each. One scan (the auto-ack hook, now
startViewedAttentionScan) replaces the keydown, pointerdown, tab
activation, group focus, workspace activation and auto-ack marker clears.

After a read, close, sleep, hibernate, retire or purge that removed a
marker, a workspace's flag is cleared once no tab or completion marker
remains on a live tab; a marker-less flag (Mark as unread, restart,
another client) is read when its workspace comes on screen. Mark as read
removes every marker in the workspace. Reads and removals dismiss the
affected panes' OS notifications in one IPC call.

* fix(unread): a bell or completion already on screen leaves no marker or flag write

Marking now asks the scan's own on-screen rule (isTabOnScreen: the window is
visible and focused and the tab is a scan target), so an on-screen bell no
longer persists the flag true then false. A completion in any split pane of
the tab on screen is viewed, matching the read. A superseded turn's
completion-marker removal settles the flag and dismisses like other removals.

* fix(unread): read the screen across hosts, groups and non-agent views; mark only for an absent user

- Arriving at a workspace in its own view settles its flag even when it shows an
  editor, browser or nothing; Settings and other views read nothing.
- A bell or completion on screen skips marking only when the user is confirmed
  present under the scan's own presence gate (input, or main reporting the
  desktop active within the shared away threshold); otherwise it marks and the
  gated scan reads it when the user returns.
- The active workspace's host is part of the screen, so switching same-id folder
  rows between hosts is an arrival on the new host.
- Removal settles lower every same-id folder row and persist each host.
- Opening and closing the floating panel both rescan.
- Every visible tab group's active tab is on screen, not only the focused one.
- One pane-key parser, one marker-tab enumeration, one removal wrapper
  (settleAttentionRemovedBy), one remove-and-dismiss; the hook is now
  useReadOnScreenAttention over on-screen-attention-scan.ts, and terminal
  keystrokes reach presence through a capture-phase window keydown.

* fix(unread): presence only from renderer input; host-exact folder flags; subjectless completions follow the screen

- Main's 'not away' answers only the scan that asked; presence is stamped
  only by trusted input (wheel included), so it can't outlive the threshold.
- isWorkspaceViewed uses the on-screen rule (workspace view showing, focused
  window, user present), so a subjectless completion marks under Settings or
  while away.
- Folder flag writes resolve the row's host like updateFolderWorkspace does
  (project-group fallback), so the row flipped and the owner persisted match.
- A runtime host purge dismisses but doesn't settle flags of surviving rows.
- DESKTOP_AWAY_SECONDS is the shared threshold's neutral name.

* test(unread): folder flag persists name the row's host

* test(unread): drop casts from the folder persistence seeds

* fix(unread): point the reliability gate and e2e fixture at the renamed on-screen hook

* refactor(unread): drop reshapes the follow-up deletes; keep the flag functions module-internal

Reverts the reconcile/mirror/activation/focus/group call sites and the isWorkspaceUnread
selector to main, removes removeTabMarkers, removeCompletionAttention and the activation
read helpers, and drives tests through public attention actions.

* test(unread): read the Dock badge flag down by putting its tab on screen

* fix(unread): keep the on-input read action removed after merging main

* fix(serve): restore a remote server's browser tabs after it restarts (#28166)

* feat(serve): keep browser tabs across an orca serve restart

A host with no window now saves the browser tabs it opens in the same shape a
desktop window saves its own, and restores them the same way: listed at once
with their last address, each page started when a client first opens or
drives it. A closed tab is forgotten; a page that ends with the process is not
a close.

* test(e2e): the server's published strip now holds its saved browser tab

* fix(serve): save browser addresses cheaply, open once, restore by page id

Review fixes: a page's address is written onto its saved records without
reloading the layout, and only on committed navigations; reopening a saved
page writes nothing; a command naming only the page finds the workspace that
saved it; a refused save no longer throws from a page event or a close. The
browser-tab map is renamed to say it lists saved tabs too.

* fix(serve): list, switch and close saved browser tabs from commands that name no page or worktree

* fix(serve): a tab list does not start saved pages; only a command acting on a page does

* fix(serve): a pageless command starts the browser tab the layout shows, not the first saved one

A host now records the browser tab a window would show: the one it just
opened, and the one a tab switch picks. With nothing running, a command
that names no page starts that tab; when the layout shows none it starts
nothing and reports no browser tab open.

* fix(serve): report the saved active browser tab as current

Claude-Session: p2-headless

* chore(ratchet): count the serve browser-tab save as a main-process layout writer

Claude-Session: p2-headless

* fix(serve): use the window's active browser tab rule, one scope, one active row

Claude-Session: p2-headless

* fix(browser): pick the active tab among tabs this host can start

A desktop document page or a remote page recorded as active no longer leaves a
pageless command with no tab. Adds a test for the first-saved-worktree scope.

Claude-Session: p2-headless

* refactor(browser): restore saved serve tabs through the page wake point

Saved serve browser tabs now start where a window would mount them: the two
existing wake helpers ask one host hook before messaging a window. The offscreen
backend reports opened and moved pages, so the command layer no longer threads
save, list, select or close hooks. No worktree named and no page running reports
no tab, as a window does.

Claude-Session: p2-headless

* fix(browser): only page creation opens a saved serve tab

A navigation now only updates an existing saved tab, so a late load after a close
cannot re-add it; a restored page sends no open, so its saved title stays.

Claude-Session: p2-headless

* refactor(browser): drop the restore mark the open/navigate split made redundant

Claude-Session: p2-headless

* refactor(browser): save serve tabs from the create command, list unstarted ones as tab rows

The create command saves the tab (it holds the group), so the backend reports only
navigations. Unstarted saved tabs are tab-info rows the existing listings take
as-is, the restore dedupe map goes (a created page registers before createTab
returns), and one lookup gives a page's unified tab id.

Claude-Session: p2-headless

* fix(browser): opening a serve browser tab no longer records it as active

Which tab shows is a view's choice; after a restart the active tab is the one a
window recorded, else the first.

Claude-Session: p2-headless

* fix(browser): give the composed browser command owners the saved-tab host member

Completes the forward merge of main's browser command split (#27576).

Claude-Session: p2-headless

* fix(terminal): store the Terminal shell setting trimmed so agent tabs start like plain tabs (#28635)

* fix(terminal): store the Terminal shell setting trimmed

The Settings field only stripped leading spaces and a hand-edited settings file loaded as-is, so a trailing space reached agent tabs, the CLI and other host-planned launches, while plain terminal tabs trimmed it in their own reader. Trim the setting when the field loses focus and when settings load (whitespace-only becomes unset), and drop the plain-tab reader's now-redundant trim.

* fix(terminal): trim the shell setting at the settings write too

A keystroke write could still store a padded shell, and Escape closes Settings without a blur, so a plain tab could launch the padded value once its own trim was gone. Trim at the settings write boundary (shared with load, like the machine name), and keep the typed text in a local draft while the field is focused.

* fix(settings): drop the shell field's draft when the field unmounts

The draft lived on the whole Terminal pane, so a field removed without a blur (another window switching to System shell, or the settings search hiding it) left a stale draft that showed later instead of the saved shell. Move it into a small input component so it is discarded with the field.

* refactor(pty): one failure cleanup and agent_error for both terminal spawn paths (#28613)

* test(pty): pin a failed provider start on both spawn lanes

Characterizes main before the two execute steps merge: the error is
rethrown, the pre-spawn hidden mark, pending registration, worktree spawn
gate, cached size and pane reservation are all undone, and only the window
lane records agent_error.

* refactor(pty): one failure cleanup and agent_error for both spawn lanes

The window (pty:spawn) and the host (runtime controller) each undid a failed
execute step with their own copy of the same cleanup: provisional execution
context, early-exit fence, pending registration, cached size and provider
state for a minted session id. Both now call failPtySpawnExecute, which also
records agent_error in one place.

recordsAgentError is TEMPORARY: host starts still record none, as on main,
until C5a-2 removes the input. The window's extra hidden-mark rollback in this
catch duplicated its outer catch, which still runs, and is gone.

* chore: re-run CI against current main

* fix(terminal): judge an agent's --shell against the runtime it runs in (#28234)

* fix(terminal): judge an agent's --shell against the runtime it runs in

`orca terminal create --shell` for an agent is now judged only against the runtime that agent
runs in, not also against the project's saved runtime. So --shell wsl.exe for an agent in a
\\wsl$ repo (saved as Windows host, or with no setting) runs instead of being refused.

* chore(ci): re-run checks against current main

* chore(ci): re-run checks against current main

* chore(ci): re-run checks against current main

* chore(ci): re-run checks against current main

* chore(ci): re-run checks against current main

* Keep CI failure excerpts bounded and strip malformed ANSI controls linearly (#28671)

* Bound and sanitize CI failure excerpts with linear ANSI stripping

* Fix excerpt regression checks and reduce bounded marker overhead

* Require signal proof for benchmark timeouts and load consumer fixtures portably

* Validate benchmark timeout framing and run required cases with a pinned CI baseline

* Strip dense generic ANSI escapes with bounded forward scanning

* Use the standard UTF8 encoder for byte-cap test oracles

* Gate CI log performance suites behind a dedicated job

The wall-clock performance suites run about 1k child workloads up to 63 MiB, so they were sharing runners with unit tests and could fail on noise. They now run only in their own `check_log_performance` job, opted in with `ORCA_CHECK_LOG_PERF=1`.

- Remove the pinned-baseline checkout and `ORCA_CHECK_LOG_BASELINE_DIR`. The baseline comparison is now a manual step that reads the pinned ref from local Git history.
- Run the current-implementation checks with `compareBaseline: false`, which skips baseline builds and audits.

* Inline check-log fixture and drop dedicated perf CI job

- Replace the captured failure-log JSON with a synthetic log built by `createCheckLogFixture()`, so no scraped job output or provenance metadata is kept in the repo.
- Remove the `check_log_performance` CI job. The log performance suites now run only when `ORCA_CHECK_LOG_PERF=1` is set, as a manual benchmark.
- Update the benchmark scripts and tests to use the new fixture helper and drop the `fixturePath` parameter.

* Remove Hermes automation support (#28519)

* Remove external cron history from the automation UI

* Simplify the automation editor

* Remove Hermes cron discovery and management

* test(cross-version): pin the ephemeral-key and source-fence downgrade tests to v1.4.224 (#28803)

v1.4.225 ships both capabilities, so the newest release no longer models an
old host. Pin each to the last release without its capability, as the other
downgrade tests in the suite already do.

* fix(terminal): name the fix that applies when --shell is refused (#28235)

A refusal names a \\wsl$ working directory first, since no runtime setting can move it out of
WSL, and names Settings > Project Runtime when the runtime comes from the global default.

* Native chat: record which agent run wrote each journal item (1/5, rebuild of #26867) (#28092)

* feat(native-chat): record which agent run wrote each journal item

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.

* test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).

* test(native-chat): an unstamped roster entry from an older build reads as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.

* test(native-chat): build the older-build roster row inline, after main removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.

* refactor(native-chat): provenance is per item only; drop per-entry stamping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.

* fix(native-chat): an item's provenance is set when it is created; revisions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.

* test(native-chat): keep the provenance test below the production lines it covers

* refactor(native-chat): open a chat visual like any file (#28437)

* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* refactor(native-chat): rename the chat visuals folder to the chat scratch folder

The per-chat folder agents write into is the chat's scratch space, not only
a visuals folder. Its root becomes <state>/chat-scratch/<hash> (the hash is
unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old
ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every
agent child inherits, Pi included. The visuals skill names the new variable
and says the folder is temporary.

* test(native-chat): keep the delivery test to the rename

The launch parity snapshot already pins that every agent drops the
pre-rename variable.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* feat(runtime-files): the host serves a chat's scratch folder as a file root

File commands accept `path:<this host's chat-scratch root>/<32 hex>` and
resolve it only when it names this host's own folder, at the file-commands
seam so the selector never reaches chat placement. They authorize against
the project roots plus the chat-scratch root, refuse files.open/openDiff
for the folder, and agentSession.readVisual's ok results carry the file's
absolute path so a client can build the selector.

* test(runtime-files): name the readVisual helper's parameter type

* test(files): drop the loop case that duplicated the dangling-link refusal

* test(runtime-files): run the readVisual RPC test, which opens a real SQLite store, under Node

* test(runtime-files): put the same-suffix folder inside the authorized root so only the selector refuses it

* refactor(native-chat): open a chat visual like any file

"Open in new tab" on an inline chat visual now opens the visual's file the
way any HTML file of the chat's workspace opens: a file:// browser tab for a
local chat (its overflow menu gains "Open source file"), the document
preview for a paired-host chat (with its existing source action and Share).
Edits save on the chat's host through the exact path: selector the host
serves, and a matching editor save re-reads the inline copy freshly. The
read-only chat-visual tab is removed.

* fix(native-chat): a visual read without a path is a malformed reply

A visual's path is always on the current host's readVisual answer, so the
inline Open needs no branch for a missing one. Also prune the deleted
chat-visual tab action from the layout-writer baseline.

* test(native-chat): give visual read replies their path without longer lines

* fix(native-chat): address a scratch file by its folder only outside every workspace

A workspace that contains the host's state folder keeps its own address, so
explorer drops, folder creation and renames inside it stay on one root.
Document preview Share reads a runtime grant's own selector and root
directly instead of passing the selector off as a workspace id.

* test(runtime-files): wait for the stores to close before removing the state folder

* fix(i18n): drop catalog strings left behind by the automation removal (#28810)

The automation removal deleted the screens but kept their strings in every
locale catalog. With no call site left, the boot-bundle catalog check counts
them as required and fails on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(wsl-mover): refuse edits to a project a WSL move is taking (D1b-5c-2) (#28761)

* feat(wsl-mover): refuse edits to a project a WSL move is taking, and drop its background bookkeeping quietly (D1b-5c-2)

* fix(wsl-mover): hide only local rows of a moved project; the host's own row keeps its id (D1b-5c-2)

* fix(wsl-mover): refuse a user's unread toggle, fence branch deletes, host setups and issue commands; settings unfreeze once the host owns a repo (D1b-5c-2)

* test(wsl-mover): the fence e2e refuses edits to moving projects and drops their bookkeeping quietly (D1b-5c-2)

* refactor(wsl-mover): name the update type the fence inspects (D1b-5c-2)

* test(wsl-mover): run the fence tests that open a real store in the Node runtime (D1b-5c-2)

* fix(wsl-mover): a host setup's own repo stays frozen once the host owns it; e2e pins the quiet drop (D1b-5c-2)

* Resume in New Native Chat for Pi (#28080)

* feat(chat): Resume in New Native Chat for Pi terminal conversations

A Pi conversation started in the terminal now offers "Resume in New Native Chat" in its Session
History row menu and its tab's right-click menu, like the other chat agents: a new Pi chat takes
it over, showing its history and continuing it.

Pi joins the shared adoption path. Its import finds the session file Session History lists under
the row id, and reads it with OMP's decoder, since OMP kept Pi's session format. A Pi chat's handle
names its session file, so the import says so and create mints the adopted handle from the found
file; the committed-create replay compares through the registration's row id, and the adopted
link id is kept to the link-id alphabet. A Pi chat forks a conversation recorded in another folder
instead of resuming it, so the workspace rule covers Pi too.

* fix(pi): preserve adopted session identity and active history

* Fix saved Pi adoption history and summary content

* test: keep only the tests that pin user-visible behavior

* feat(relay): measure each host hello's pre-auth time on the cell (#28695)

* feat(relay): measure each host hello's pre-auth time on the cell

Adds preAuthLatencyMs to the per-cell orca_relay_runtime_metrics line: per
admit path (db, reserve) and outcome (admitted, refused), a count plus
p50/p95/p99/max for the window. Each sample runs from the cell accepting the
host control WebSocket to the hello being admitted (proof verified) or
refused. Samples are kept in a bounded reservoir, shared with the control
round-trip metric. No behaviour, wire, or flag change.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): count abandoned hellos and publish mergeable pre-auth buckets

- Record a third outcome, abandoned, for a host that hangs up before a decision or before
  the cell acts on one, so the slowest hellos no longer drop out of the window.
- Add non-cumulative bucket counts per group (bounds 50..5000 ms and +Inf, 450 exact) so
  the share at or under 450 ms sums exactly across windows and cells.
- Split refusals that end before an admission check answers into a beforeCheck path.
- One refuse() closure per phase records the metric, and the path is passed to settle.
- Drop the half-used LatencyReservoir type; the shared reservoir function stays.
- Replace test casts with a typed accessor or SAFETY comments.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* docs(relay): note pre-auth path labels for late throws and the combined role

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): end the restart-safe quiet at the drain's pace window, not a window after it (#28677)

* fix(relay): end the restart-safe quiet at the drain's pace window, not a window after it

A paced drain tells its last host when the window ends, so the restart-safe
wait has to see an empty runtime reaching past that point. It counted a full
window of quiet samples from the first empty one instead, and the runtime only
empties as the window ends, so every same-cap cell waited the window twice:
about 25 minutes per Asia cell at 1200 s on 2026-10-13, and 5 minutes per
300 s cell.

The window is now measured from the verifier's own start, which runs after the
cell accepted the drain and so ends no earlier than the cell's own window. A
pass still needs two consecutive restart-safe samples, the last taken at or
after that end, and any non-zero runtime counter or open migration still
restarts the count. Progress lines report paceWindowRemainingMs in place of the
old sample target.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): drop the same-cap drain timeout's double-wait allowance and stale quiet wording

The drain step's timeout added the window's excess over 300000 only because
restart-safe used to wait a second window after the cell emptied, so it is
now the 15-minute migration lease plus the window for every pace (35 minutes
at 1200000, unchanged at 300000 and below). Docs, workflow comments and the
shadow gate's settledAfterSeconds note now describe the window-end rule. The
always-quiet test starts its clock late, so a window measured from zero
fails it, and the overlapping late-control test is folded away.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* docs(relay): state the restart-safe window-end rule exactly

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* docs(relay): the slowest restart-safe wait is now 35 minutes

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* ci: run the Electron browser probes serially, out of the unit shards (#28799)

The three popup compatibility probes ran in the parallel unit shards and
timed out at their 14s in-process deadline when other files starved the
runner. Move them into the package job's serial Electron lifecycle step,
and stop the six probes already in that step from also running in the
shards.

Claude-Session: p2-headless

* fix(pty): report agent start failures the same way from every launch path (#28724)

* feat(telemetry): count agent start failures from the host too

The host spawn path (CLI, phone, orchestration workers, headless automations,
worktree startup) now records agent_error when starting an agent fails, as the
window path already did. Removes C5a's TEMPORARY recordsAgentError input; the
host already threads each launch's telemetry, so failures get real kinds.

* fix(telemetry): count a claimed start that several requests joined once

Concurrent resumes of one agent session join one claimed start. When it
fails, every joined request ran the host catch and recorded agent_error, so
one failed start counted once per request. Only the request that ran the
start records it now; the others still clean up.

* fix(telemetry): only skip agent_error for a request that joined another start

The host marked a request as joined before calling ensure, so a claim that
refused the request on its own (conflict, capacity, unreachable owner) recorded
no agent_error. ensure now reports the join itself, right before awaiting the
other request's start.

* chore: re-run CI after main's localization fix (#28810)

* fix(new-tab): Enter on a visited site's name opens the site, not a file (#28804)

* Rank browser history above fuzzy file matches in the omnibox

Scattered-character fuzzy file matches are weak evidence, so a remembered
page should appear above them. Exact and literal file matches and typed
URLs still rank above history. Adds a test for typing a host like
linear.app, which should rank the page above unrelated fuzzy files.

* Anchor omnibox history on the typed URL instead of demoting fuzzy files

The real bug: after visiting https://linear.app/, typing `linear.app`
made Enter open an unrelated file. The history row removes the typed-URL
row it duplicates, but was then placed below every file match, so the
page lost the top slot the URL row had.

History now sits with the typed URL row (in its slot when it replaced
it). Queries with no typed URL keep the old rule: below every file match.

This replaces the previous commit's rule of ranking history above
"fuzzy" files. That label covers every non-exact match for queries with
a slash, a dot suffix or a space, so a code-host page whose title holds
the file path took Enter away from local rows for path queries (or left
nothing selected when pasted). It also left `localhost` broken when a
file name contained it.

* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d) (#28820)

* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d)

* fix(wsl-mover): gate again under the fence, swap journal phases atomically, keep sessions frozen through commit (D1b-5d)

* test(wsl-mover): the fence e2e walks the journal one phase at a time, as a move writes it (D1b-5d)

* feat(native-chat): Share a chat visual from the chat (#28479)

* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* refactor(native-chat): rename the chat visuals folder to the chat scratch folder

The per-chat folder agents write into is the chat's scratch space, not only
a visuals folder. Its root becomes <state>/chat-scratch/<hash> (the hash is
unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old
ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every
agent child inherits, Pi included. The visuals skill names the new variable
and says the folder is temporary.

* test(native-chat): keep the delivery test to the rename

The launch parity snapshot already pins that every agent drops the
pre-rename variable.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* feat(runtime-files): the host serves a chat's scratch folder as a file root

File commands accept `path:<this host's chat-scratch root>/<32 hex>` and
resolve it only when it names this host's own folder, at the file-commands
seam so the selector never reaches chat placement. They authorize against
the project roots plus the chat-scratch root, refuse files.open/openDiff
for the folder, and agentSession.readVisual's ok results carry the file's
absolute path so a client can build the selector.

* test(runtime-files): name the readVisual helper's parameter type

* test(files): drop the loop case that duplicated the dangling-link refusal

* test(runtime-files): run the readVisual RPC test, which opens a real SQLite store, under Node

* test(runtime-files): put the same-suffix folder inside the authorized root so only the selector refuses it

* refactor(native-chat): open a chat visual like any file

"Open in new tab" on an inline chat visual now opens the visual's file the
way any HTML file of the chat's workspace opens: a file:// browser tab for a
local chat (its overflow menu gains "Open source file"), the document
preview for a paired-host chat (with its existing source action and Share).
Edits save on the chat's host through the exact path: selector the host
serves, and a matching editor save re-reads the inline copy freshly. The
read-only chat-visual tab is removed.

* feat(native-chat): share a chat visual from the chat

The inline visual's toolbar gains Share beside Open. It publishes under the
file's own key on the chat's host, the key the opened preview and editor use,
so every surface updates one link. The published text comes from a fresh read
begun after the click; a failed read publishes nothing and says the visual
couldn't be read. The toolbar stays shown while Share's popover is open.

* fix(native-chat): a visual read without a path is a malformed reply

A visual's path is always on the current host's readVisual answer, so the
inline Open needs no branch for a missing one. Also prune the deleted
chat-visual tab action from the layout-writer baseline.

* test(native-chat): give visual read replies their path without longer lines

* fix(native-chat): address a scratch file by its folder only outside every workspace

A workspace that contains the host's state folder keeps its own address, so
explorer drops, folder creation and renames inside it stay on one root.
Document preview Share reads a runtime grant's own selector and root
directly instead of passing the selector off as a workspace id.

* fix(native-chat): show a chat visual's Share whenever it shows

Every read now carries the visual's path, so Share no longer waits on one; its
key comes from the path the click's fresh read returns.

* test(runtime-files): wait for the stores to close before removing the state folder

* fix(opencode): refuse a picked OpenCode model in any \\wsl$ repo (#28236)

OpenCode in a \\wsl$ repo runs in WSL whatever the project's setting, and Orca can't verify a
model there yet, so picking one is refused, as it already is for a project set to WSL, instead
of being checked on Windows.

* fix(mobile): workspace list host badge, "workspaces" wording, and search keyboard on the page (#28824)

* fix(mobile): workspace list host badge, wording, and search keyboard on the page

- Host badge no longer shrinks to "m…"; the branch/path text gives up the space.
- Search placeholder, accessibility label, and empty states say "workspaces".
- The page's workspace list no longer closes the keyboard on each narrowing keystroke:
  react-native-web dismisses on-drag on every scroll event, including the offset clamp.

* fix(mobile): let the row path take only the leftover space instead of pinning the host badge

A non-shrinking badge overflowed narrow, indented rows; a zero-basis path keeps a short host name whole and still lets the badge shrink when the row is genuinely too narrow.

* fix(mobile): reword the row path's layout comment

* refactor(mobile): inline the page's keyboard dismiss choice and use flex: 1 for the row path

Drop the platform split and its react-native-web test: an inline Platform.OS check states the
same choice where it is used. flex: 1 renders identically to flexBasis 0 + flexGrow 1.

* fix(relay): supersede a reserve seat from the feed once a later, newer join shows (#28716)

* test(relay): check invariant 4 (one demotion winner) in the step-5 simulation

Every demotion now names its winner (the newer booking, or the row). The model
flags a director that names a different winner for a divergence another already
named, and a host whose two seats are each demoted for the other. A fault where
directors break level ties by their own cell order shows the check fires.
Violations are capped per invariant so one flood cannot hide another class.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* test(relay): bound unseen duplicates by the silence close and assert all invariants on contested ties

A reserve seat the booking director's map had not seen (joined, then half-open within a poll)
is never superseded and has no lease to re-check, so it lasts until the cell's control silence
close (75 s + one 15 s ping). Invariant 3 now uses that bound for such seats, and the
level-divergence config asserts every invariant.

Stale maps now judge a divergence another director already demoted, so the config's
"contested" counter (divergences judged by two or more directors) is the case invariant 4
guards. The silence-close constant now matches production (90 s, not 100 s).

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* test(relay): raise the floor on map-judged demotes and count only level contests

A reconcile whose map has not seen another director's demotion still demotes in
production, which raises its epoch floor; the model now does too. The contested
counter counts only same-epoch pairs, the only ones invariant 4 can split. The
keep-this-seat rule is one helper.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* refactor(relay): name the simulation's fault-kept seat check for the fault

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): supersede a reserve seat from the feed once a later, newer join shows

A half-open seat the booking director's map had not yet seen was never demoted and held its
cell until the 75 s control silence close. Directors now demote it once both joins are in the
map; the simulation holds invariant 3 at the supersede bound for it.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): outrank only past cell clock skew, reuse demoteSeat, keep invariant 3's loose clock running

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* refactor(relay): share the outrank rule with the simulation, pin the skew boundary

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): count only confirmed level demotions and every second judgment, and bound duplicates by placement records alone

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): a drained seat keeps its drain grace under a demote, and the feed reports each seat once

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* refactor(relay): name the outranked-report bound and the reconcile backstop

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* refactor(tabs): delete unused copy-tab and merge-group store actions (#28836)

* refactor(tabs): delete unused copy-tab and merge-group store actions

copyUnifiedTabToGroup and mergeGroupIntoSibling had no callers outside
the store and its tests. Tests that used the copy action as a setup step
now create the duplicate tab directly, the same call the action made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(tabs): follow the split-ratio file rename in the layout-writer baseline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(layout): re-project only the written partition in the layout stream (#28845)

The layout stream (layout.subscribe, still unused) re-loaded every session
partition and ran the load's save-and-diff report after each write. A save
now names the partition it wrote, the stream re-loads only that partition
without the report, and the renderer layout client no longer re-subscribes
on a timer. Adds a latency spec variant with a subscriber attached.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(wsl-mover): background writers step aside for a project a WSL move is taking (D1b-5c-3) (#28835)

* feat(wsl-mover): background writers back off a project a WSL move is taking (D1b-5c-3)

* fix(wsl-mover): re-check before renames, quiet owned automations, stable folder ids (D1b-5c-3)

* test(wsl-mover): seed journals one phase at a time and advance existing moves (D1b-5c-3)

* fix(wsl-mover): a branch rename that already ran settles even when the move skips the folder (D1b-5c-3)

* fix(i18n): correct the Japanese label for the Cleaned browser identity (#28526)

The Japanese option read 掃除済み ("housekeeping done"), while the migration notice that points to the same setting already calls it クリーン. Use クリーン in the option and its settings-search keyword.

Also restore the literal CLI command in the reset hint, which translation had re-capitalised in ja, zh, and fr so it could not be copied and run.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* fix(i18n): use クリーンアップ in the Japanese VM cleanup stop dialog (#28533)

The dialog title and cancel button said 掃除 (household cleaning), while its own description and confirm button already say クリーンアップ. Use クリーンアップ throughout.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* refactor(terminal): publish new tabs together with their startup command (#28848)

Seven call sites created a terminal tab and then queued its startup command
in a second store write. Pass the command through createTab's existing
pendingStartup option instead, so the tab and its command land in one write.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(daemon): keep every pane in main's layout through a terminal service restart (#28852)

Restarting the terminal service ends each session with a synthetic -1 exit. Main
read those as ordinary deaths and retired every terminal surface from the saved
layout, while the window kept drawing them. Phones and the CLI lost the tabs, and
the next launch restored a split as a single pane.

The restart now labels those exits as replaced, and main records them in its
existing intentional-stop register, the one terminal restarts already use. The
runtime keeps the surface and the window receives the same replaced label.

Claude-Session: p2-headless

* chore(relay): pin staging c4 to image G for E-pre (#28854)

G is main 2de73812 (relay sha256:07b5bf67…), which adds the cell's t_preauth latency metric.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* test(session): compare what the window shows before and after the shared session loader (#28842)

* test(session): compare what the window shows before and after the shared session loader

Boots the window's real startup hydration on each saved-profile fixture as
stored and as the shared loader rewrites it (load then save), and compares
tabs, groups, active tabs, pane trees, terminal bindings, editor and browser
tabs. Today's eight differences are listed with the loader rule behind each;
the list may only shrink, so any new difference fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(session): let the parity test own its store so the harness reads no host focus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(session): restore the parity boot's UUID spy even when the boot throws

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(checks): add optional job log outcome

* fix(checks): preserve host capture lifetimes and safe outcomes

* fix(checks): retrieve and retry bounded failed-job logs

* test(checks): cover queued snapshots and captured error safety

* test(checks): verify sanitized host excerpts and paired failures

* test(checks): identify measurements and preserve paired cleanup

* fix(checks): hold check log lifetimes until processes exit

- Nested gh calls now stay registered until their child closes and their
  scope seals, so a check-details request no longer reports terminated
  while its process is still running.
- Failed-job log requests leave the shared in-flight cache only after the
  result is delivered and the process has exited, so a replacement request
  cannot start while the old child is still alive.
- Check-details metadata calls wait for their own termination in a
  `finally`, replacing the chained `previous` promise that serialized calls
  and could skip cleanup on error.
- Abort handling uses `assertCheckDetailsActive` directly, and the test
  fixture moved to a shared config script.

* fix(checks): reuse job logs with pinned credentials across runtimes

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Sojiro.O <53679703+sojiro-o@users.noreply.github.com>
brennanb2025 added a commit that referenced this pull request Oct 15, 2026
* fix(worktrees): a retried or resumed delete only removes the folder it started deleting

A failed worktree delete stays listed for a retry, and an interrupted one is
finished at the next start. Both later deletes force-deleted whatever was at
the recorded path, proven only by "git does not list it and it has no .git",
so an ordinary folder the user put at that path since was deleted with its
files, with no click needed in the startup case (STA-9338).

An accepted removal now records the checkout directory's identity (device,
inode, creation time) in its durable record. A retry, a startup finish, and
the in-slot check before the force-delete refuse any folder at the path that
is not that directory. Startup no longer rebuilds a missing .git for a
registered checkout unless the folder is the recorded one. Records without an
identity (older builds, or no creation time) are only finished through Git's
own checks and never force-deleted.

* fix(worktrees): identify the accepted checkout by inode and a real creation time only

The device number changes when an external volume is attached again, so a
genuine leftover read as a different folder. Windows reports a missing
creation time as a negative number rather than 0, which slipped past the
guard.

* refactor(worktrees): every background removal states the checkout identity it accepted

A new acceptance site that forgot the identity used to compile and silently
write records no retry or resume could ever finish. The resume tests also
assert the identity was read, so their refusals can't pass as unrecorded.

* fix(worktrees): a delete that leaves the user's folder in place also ends the workspace

When Orca decided the folder at a failed or interrupted delete's path was not
the one it started deleting, it dropped the removal record but kept the
workspace's metadata. That metadata still says Orca created a checkout there,
and with no record left, the next Delete of the same workspace (a runtime
Delete from mobile, the CLI or a paired client, or a second desktop Delete on a
row not yet refreshed) went to the cleanup that deletes an Orca-created folder
Git no longer lists, and removed the user's folder after all.

Now, when a removal ends without deleting while Git does not list the path (a
listing ends a failed delete, or a retry, resume or first delete fails with no
leftover to show), the host runs a finished delete's bookkeeping: metadata,
terminal history, caches and change events. The folder and the branch are left
alone, and nothing is ended while Git still lists the path.

* test(worktrees): cover the in-slot folder check when Git cannot remove a registered checkout

Reverting that check left every test green: only the unregistered branch's
check had a test where the folder changes during the delete-slot wait.

* fix(worktrees): a resumed or retried Git delete checks the folder inside the delete slot

The check that the folder at the path is still the one Orca accepted ran
before the watcher gate and the delete-slot wait, which can last minutes behind
other large deletes. A folder restored there meanwhile with a copy of the
checkout's .git file passes Git's own validation, so `git worktree remove
--force` deleted it. The resume and retry now repeat the check inside the slot,
right before Git deletes, as the non-Git branch already did; the refusal skips
the Windows recovery that could otherwise finish the delete itself. A normal
first delete is unchanged.

* test(worktrees): assert the second desktop Delete refuses the user's folder

The handler result is untyped, so the previous catch-all did not typecheck; the
assertion now names the refusal the Delete gets once the workspace has ended.

* test(worktrees): the desktop Delete test's stand-in host ends the workspace without a cast

* fix(worktrees): a listing asks Git before dropping a failed delete, and ends its workspace in the same step

* fix(worktrees): a failed delete Git cannot be asked about stays failed for the next listing to decide

* test(worktrees): the failed-delete record test states that Git still registers the checkout

* fix(worktrees): a restart keeps a failed delete whose checkout is gone, so the first listing ends its workspace

* fix(worktrees): a folder Orca cannot read keeps its failed delete and is never deleted

* fix(worktrees): a failed delete leaves the disk only after its workspace's end is saved

* fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place

* test(worktrees): the desktop Delete test's stand-in host returns nothing

* fix(worktrees): a Delete of a failed delete's leftover whose .git Orca cannot read says so

* Revert "fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place"

This reverts commit 68d6598.

The shortcut reaches no real client: the CLI and mobile list the workspace before deleting it, which ends the failed delete first, so they still answer not found (the folder is kept either way).

* fix(worktrees): a failed delete Git could not be asked about is dropped once Git says it still registers the checkout

* revert to main before the standard-model rework

* fix(worktrees): stop deleting Orca-created folders that have no .git and that Git no longer tracks

Deleting an Orca-created workspace whose folder Git no longer tracked and that had no `.git` force-
deleted the whole folder once the user confirmed force. "Orca made a workspace here once" and "there
is no .git" do not prove the folder still holds that checkout: an ordinary folder the user put at
the path was deleted.

Such a folder now gets the existing "Refusing to delete unregistered worktree path" answer, forced or
not. Once the user removes the folder, Delete takes the existing missing-path branch and drops the
workspace. An orphaned worktree whose `.git` file points back at this repo is still deleted after
the user confirms force, as before.

* fix(worktrees): delete a failed or interrupted workspace only through Git

A delete that failed partway (Git dropped the registration, files left) stays listed as "Delete
failed", and a delete a quit interrupted is finished at the next launch. Both later deletes removed
the folder with Orca's own file removal, proven only by "Git does not list it and there is no .git".
An ordinary folder the user put at that path was deleted, at startup without any click.

Orca now deletes a checkout only through Git, plus the existing cleanup right after a Git delete that
succeeded in the same operation:

- Delete on a failed row: if Git registers a checkout at the path, the normal delete runs. If the
  folder is still there and Git does not register it, Delete refuses with "Git no longer tracks
  <path>, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the
  folder is gone." and keeps the row. With the folder gone, Delete prunes Git's stale entry, deletes
  the branch as recorded and drops the workspace.
- Startup finish of an interrupted delete: a checkout Git still registers is removed with
  `git worktree remove` and the user's recorded force choice, no longer always forced. If Git refuses
  or fails, the row shows Git's error. The step that rewrote a missing `.git` so Git could force the
  delete is gone. A folder Git does not register becomes a failed row and is not deleted; with the
  folder gone, the rest of the delete finishes.

Records written by earlier daily builds still load; fields this version does not know are ignored.

* test(worktrees): read Git's listing before the Delete call in the real-Git failed-delete suite

* docs(worktrees): say when a failed delete's record ends under the Git-only delete

* fix(worktrees): keep main's handling of a startup finish Git refuses, and say the listing drops the workspace

A startup finish that Git refuses for a checkout Git still registers now clears its record and
leaves Git's normal row, exactly as an in-session failure does on main; the resumed-only failed
record and the listing that put Git's error on Git's own row are gone, and the listing is main's
again. An unregistered folder that still exists is still a failed row, and nothing is deleted.

The refusal now reads: "Git no longer tracks <path>, so Orca won't delete it. Remove the folder
yourself, and Orca will drop this workspace from the list." The listing drops a failed record once
its folder is gone, as on main.

* fix(worktrees): finish a failed delete's branch and push-target remote when it fails

A delete Git fails partway keeps a "Delete failed" row for the folder Git left. The row's record
also carried the branch delete and the push-target remote cleanup for later, but the listing drops
the record once the user removes the folder, so both were silently left behind. Now, once Git no
longer registers the checkout, the failure runs that Git side right away (merged branch only,
stale admin entry, push-target remote), in the one settle path desktop and runtime share. It
deletes no files, and a failure of this bookkeeping is logged without touching the failed row.

* fix(worktrees): unregister an interrupted delete's checkout whose .git Git already deleted

A quit mid-delete can leave a checkout Git still registers but whose .git link Git already
deleted (Git deletes it first on Windows). No `git worktree remove` can validate it, so the startup
finish failed, the record was cleared, and the workspace came back as a normal row no Delete could
ever remove. The startup finish now has Git prune that registration (refused while locked; every
file kept) and then treats the folder as one Git no longer registers: the "Delete failed" row with
the remove-it-yourself message, ending once the user removes the folder. Adds the non-forced
partial-delete case: Git refuses, the files stay, and the row returns as before.

* fix(worktrees): name a worktree Git registers inside a failed delete's folder before asking the user to remove it

The refusal for a folder Git no longer tracks tells the user to remove the folder. When that folder
holds another worktree Git still registers, doing so would delete that worktree's files too. Delete
on the failed row and the startup finish now run the existing nested-worktree check first, so its
message ("contains another registered worktree: <path>") wins, as it did before this branch.

* fix(worktrees): don't prune other worktrees when a delete fails

The Git side of a failed delete started with a repo-wide `git worktree prune`. It did nothing for
the failed delete, whose admin entry Git had already dropped, but it unregistered any other
worktree whose folder was missing at that moment (an unmounted drive), so Orca dropped that
workspace too. The failure now runs only the branch step; the prune stays where it was on main,
in the folder-gone finish and at startup.

* test(worktrees): cover the push-target cleanup every delete job wires for a failed delete

A failed delete removes the push-target remote Orca added through the hook each job creator passes: desktop Delete, runtime Delete, and the startup finish. Nothing tested that wiring; these tests fail when any creator drops it.

* fix(worktrees): say the leftover folder was left on disk instead of what Orca won't do

(cherry picked from commit 3825530)

Backport to 1.4.224: kept 1.4.224's module names (orca-runtime-remove-managed-worktree.ts, orca-runtime-resolve-worktree-removal-target.ts, mixin classes without abstract declarations) and its SSH-aware signatures (isAlreadyRemovedWorktreePath(repo, ...), isRuntimeWorktreePathMissing(hostId, ...)); the failed-row retry stays local-only (!repo.connectionId) and is now awaited; dropped the removed isLocalGitRepository from the host-home test's module mock. The SSH orphan cleanup that 1.4.224 still has (fsProvider.deletePath) keeps the same .git-file-points-back-at-this-repo proof and force confirmation the PR keeps for the local orphan cleanup; the Orca-created no-.git leftover branch the PR removes was local-only on 1.4.224 and is removed in both the desktop and runtime paths. The RemovalRuntime retry-override test in worktree-removal-execution-host.spec.ts is not on this base, so its edit was dropped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant