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.
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.
git_worktree.py (2,084 lines), worktree_sorcar_agent.py,
worktree_pool.py, merge_flow.py, task_runner.py, the path-remap
helpers, and sub-agent dispatch — and traced every path that creates, reuses, merges, reclaims, or
deletes a worktree. Each finding was reproduced by a failing end-to-end test before being 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.
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.
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.
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".
| Area | Verdict |
|---|---|
| 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. |
.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..env,
.venv): a functional gap (tasks may misbehave), not a loss of existing data.test_worktree_ignored_file_rescue.py, test_worktree_discard_subagent_wait.py,
and test_worktree_rescue_hardening.py — real git repos, real worktrees, real threads and
processes, no mocks.uv run check --full (ruff, mypy, pyright, compileall, API docs) passes.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