Can work get lost in worktree mode? — Audit, fixes, and verification

Question: KISS Sorcar runs every task in a throwaway git worktree (<repo>/.kiss-worktrees/kiss_wt-*) that is deleted after the task's changes are merged or discarded. Can any work — the user's or the agent's — be silently lost because of this?

Answer: Yes — the audit found three real loss vectors, and an independent review found four more defects and gaps (including bugs in the first round of fixes). All seven are now fixed and covered by end-to-end tests; three narrow residual risks remain and are listed at the end. Committed and tracked-file work was already well protected by the existing design (baseline commits, auto-commit, stash/pop, orphan reclaim, preserve-for-review markers — the code had been through 18 prior audit rounds). Every problem found involves state git does not track: ignored files, live writers, and cross-process ownership.

How worktree mode works (30 seconds)

When a task starts inside a git repo, the agent creates a branch kiss/wt-<id> and a linked worktree, copies the user's uncommitted changes into it (recorded as a baseline commit), and runs the task there — the user's checkout is never touched while the task runs. Afterwards the task's changes are auto-committed and either squash-merged into the original branch or discarded; the worktree directory is then removed with git worktree remove --force. Crashed tasks leave "orphan" worktrees that a reclaim pass merges or preserves on the next start. A background pool pre-creates one spare worktree per repo to hide checkout latency.

Task starts worktree + baseline Task runs writes in worktree Auto-commit git add -A + commit Merge / discard then delete worktree F1: ignored files skipped by add -A, deleted with dir F2: deleted under a live sub-agent / occupied worktree F3: spare pool destroys external content on consume R1: 2nd process reclaims a LIVE worktree (pid-blind) Fixes (all covered by end-to-end tests) F1: atomic, contained, fail-closed rescue of ignored files into the main repo before every removal F2: discard waits for sub-agents / defers; occupied-worktree guard on all automatic actions F3: contaminated spares preserved, never consumed  •  R1: owner-pid liveness guard in reclaim
The worktree lifecycle with the four loss points found (red) and their fixes (green).

Method

Findings and fixes

F1 — Git-ignored task output was destroyed on every teardown (fixed)

The post-task auto-commit uses git add -A, which skips files matched by .gitignore; the teardown then deletes the whole worktree. So a task that downloaded a dataset into an ignored data/ directory, or wrote an ignored *.csv or .env, reported success while its output silently vanished — output that a non-worktree run would have left on disk. The changed-files probe also cannot see ignored files, so a task whose only output was ignored got auto-classified as "no changes" and discarded.

Fix: GitWorktreeOps.rescue_ignored_files() runs before every removal that keeps work (merge, auto-release, orphan reclaim, automatic empty-branch discard). Hardened per review: destinations are landed atomically (hard-link, O_CREAT|O_EXCL fallback — a racing writer can never be truncated); an existing main-tree file is never overwritten (identical bytes are skipped; differing content is preserved beside it as <name>.kiss-rescued-<ns>); the nearest existing ancestor must resolve inside the repo (a symlinked directory cannot carry the write outside); regenerable caches (.venv, node_modules, __pycache__, …) are skipped; and the rescue fails closed — if any file cannot be landed, the worktree is preserved instead of removed, because it holds the only copy. A user-explicit Discard click still throws everything away, as asked.

F2 — Worktrees deleted under live writers (fixed)

The commit path waited for abandoned sub-agent threads before deleting the directory, but discard() did not — an automatic empty-branch discard could delete the directory a sub-agent was still writing into. The review also showed the server cleared the _pending_review flag before the discard ran (so a tab close during a deferred discard could auto-merge work the user asked to throw away), treated deferrals as terminal, stripped the webview's only retry buttons, and let internal automatic discards bypass the occupied-worktree guard that blocks user-initiated ones.

Fix: discard() now waits up to 5 s for abandoned sub-agents and returns a retryable "Discard deferred" result (keeping the worktree and the pending handle) when one is still running or the ignored-file rescue fails; _pending_review is cleared only past every deferral point; all automatic actions (merge and discard) refuse to touch a worktree another tab is running a task inside; deferred results carry retryable: true and the webview keeps the Merge/Discard bar for them.

F3 — The spare-worktree pool destroyed external content (fixed)

Consuming a pooled spare ran git reset --hard + git clean -fdq, destroying anything an external writer had put into the idle directory, and discard_all() deleted spares unconditionally — while the crash-reclaim pass for the very same situation preserves a spare with unexpected content. Fix: take_spare() now refuses (and preserves) a spare with uncommitted changes, ignored files, or commits reachable nowhere else, falling back to a fresh inline worktree; discard_all() applies the same guard.

R1 — A second Sorcar process could reclaim a live worktree (fixed)

The reclaim pass protects running tasks with an in-memory exclusion set and a per-repo lock — both process-local. A kiss-web daemon and a kiss CLI run sharing one repo could therefore auto-commit, squash-merge, and delete each other's live worktrees mid-task (reproduced by the reviewer). Fix: every worktree now records its owner's pid in branch config (branch.<name>.kiss-owner-pid); reclaim skips any worktree whose foreign owner process is still alive, and still reclaims worktrees of dead owners. The safe direction on any doubt is "skip".

What was checked and found already safe

AreaVerdict
Tracked/staged/untracked (non-ignored) user changes at task start Safe — copied into the worktree and recorded as a baseline commit; setup failures fall back to direct execution without touching the originals.
Merging (squash / cherry-pick, the conditional -X theirs), stash push/pop, conflict aborts Safe — conflicts keep the branch for manual resolution; the stash double-apply and partial-abort cases are guarded; both auditor and reviewer verified.
Crash/restart recovery (orphan reclaim, preserve-for-review markers, dirty-main guards) Safe within one process — reclaim never merges preserved, dirty-main, wrong-branch, or spare worktrees (cross-process case was R1, now fixed).
Sub-agents (run_agent / run_parallel) Safe — they inherit the parent's worktree work_dir, so their writes are committed with the parent's work; abandoned threads are now respected by every teardown path.
Absolute-path writes to the parent repo from inside a worktree task Safe — file tools remap parent-repo paths into the live worktree; stale worktree paths remap back after teardown.
Task results/history referencing worktree paths Safe — persistence strips the ephemeral .kiss-worktrees/… segment at every recording boundary.

Residual risks (documented, not fixed)

  1. Ignored files vs. git itself: if a task removes an ignore rule and commits its own version of a previously ignored file (e.g. .env), the squash merge overwrites the user's ignored copy — git treats ignored files as expendable during checkout/merge. Likewise discard()'s checkout of the original branch can overwrite an ignored file if the user manually switched branches while a task was pending. Both need the task to take unusual actions, and both exist in plain git usage too.
  2. Worktree tasks cannot see the main tree's ignored files (.env, .venv): a functional gap (tasks may misbehave), not a loss of existing data.
  3. Cross-repo writes by worktree tasks are not auto-committed (only non-worktree tasks run the changed-repos pass). The files persist on disk; they are just left uncommitted.

Verification

Changed files

src/kiss/agents/sorcar/git_worktree.py          rescue engine, owner-pid guard, reclaim fail-closed
src/kiss/agents/sorcar/worktree_sorcar_agent.py rescue wiring, deferred discard, PRESERVED_RESCUE_FAILED
src/kiss/agents/sorcar/worktree_pool.py         contaminated-spare guards (consume + discard_all)
src/kiss/server/merge_flow.py                   occupied-worktree guard, retryable deferrals, no premature
                                                _pending_review clear
src/kiss/server/task_runner.py                  deferred-discard warnings, occupied-worktree skip
src/kiss/agents/vscode/media/main.js            keep Merge/Discard bar on retryable failures
src/kiss/tests/...                              3 new test files (20 tests), 2 updated