Skip to content

fix(worktree): prevent primary worktree deletion on cleanup#391

Merged
dimakis merged 3 commits into
mainfrom
fix/worktree-primary-dedup
Jun 20, 2026
Merged

fix(worktree): prevent primary worktree deletion on cleanup#391
dimakis merged 3 commits into
mainfrom
fix/worktree-primary-dedup

Conversation

@dimakis

@dimakis dimakis commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Root cause: When the base repo (mgmt) is also listed in .mitzo.json repos, discoverSessionWorktrees adds both "primary" and "mgmt" entries pointing to the same worktree directory. On session cleanup, cleanupSessionWorktrees skips "primary" but removes "mgmt" — destroying the primary worktree underneath the active SDK session.
  • Symptom: "Path does not exist" errors mid-session, especially on iOS where frequent WS reconnects trigger zombie-abort → resume cycles that hit the cleanup path.
  • Fix (two layers):
    • discoverSessionWorktrees: skip secondary repos whose path matches primaryRepo
    • cleanupSessionWorktrees: guard against removing any secondary whose worktree path matches the primary (defense in depth)

Test plan

  • New test: discoverSessionWorktrees deduplicates secondary repos matching primaryRepo
  • New test: cleanupSessionWorktrees skips secondary whose path matches primary worktree
  • All 87 existing tests pass (worktree.test.ts + chat.test.ts)
  • Deploy and verify no more "Path does not exist" errors on iOS sessions

🤖 Generated with Claude Code

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 6 issue(s) (2 warning).

packages/client/src/sse-connection.ts

Worktree dedup fix is solid defense-in-depth but relies on string equality for path comparison without normalization. The SSE reconnect refactor has a race condition: the fire-and-forget reconnect POST may not arrive before flushed pending sends.

  • 🟡 bugs (L224): Race condition: doPost('reconnect', ...) is fire-and-forget (not awaited), but flushPendingSends() on line 234 runs immediately after. If there are queued sends, they race the reconnect POST to the server. If a send arrives before the reconnect, the server hasn't reattached the session yet — events may be lost or misordered. The reconnect POST should be awaited before flushing pending sends. [fixable]

server/worktree.ts

Worktree dedup fix is solid defense-in-depth but relies on string equality for path comparison without normalization. The SSE reconnect refactor has a race condition: the fire-and-forget reconnect POST may not arrive before flushed pending sends.

  • 🟡 unsafe_assumptions (L243): Path deduplication uses strict string equality (repoPath === primaryRepo) with no normalization. loadRepoConfig in repo-config.ts stores paths as raw strings from .mitzo.json. If the primary and secondary differ by trailing slash, symlink, or relative vs absolute form, the guard fails and both entries are added. Consider using path.resolve() or fs.realpathSync() before comparison. [fixable]

server/chat.ts

Worktree dedup fix is solid defense-in-depth but relies on string equality for path comparison without normalization. The SSE reconnect refactor has a race condition: the fire-and-forget reconnect POST may not arrive before flushed pending sends.

  • 🔵 unsafe_assumptions (L1238): Same string-equality issue as discoverSessionWorktrees: path === primaryPath compares worktree paths constructed via join(repoPath, ...). If the repo paths weren't normalized at discovery time, this guard also fails. However, since both paths are constructed with join() from the same source, the cleanup guard will match whenever the discovery guard matches — so this is consistent, just not robust against symlinks or non-canonical paths. [fixable]
  • 🔵 style (L1233): The cleanupSessionWorktrees guard is defense-in-depth against duplicates that discoverSessionWorktrees should have already filtered. The comment explains why, which is good. But this means there are two dedup mechanisms that must stay in sync. Consider extracting the path normalization into a shared helper so both call sites use identical logic. [fixable]

packages/client/src/__tests__/sse-connection.test.ts

Worktree dedup fix is solid defense-in-depth but relies on string equality for path comparison without normalization. The SSE reconnect refactor has a race condition: the fire-and-forget reconnect POST may not arrive before flushed pending sends.

  • 🔵 missing_tests (L293): No test verifies the ordering contract: reconnect POST must complete before pending sends are flushed. A test where mockFetch delays the reconnect response and verifies sends aren't dispatched until after would catch the race condition identified above. [fixable]
  • 🔵 missing_tests (L320): No test covers what happens when the reconnect POST fails (fetch rejects or returns non-ok). The current code silently swallows the error, but the test suite should verify the client degrades gracefully (e.g., still flushes sends, doesn't throw). [fixable]

// watch, no reattach, no event replay. This POST ensures every
// reconnect (auto or explicit) triggers the full server-side
// reconnect flow: watch + reattach + event replay.
if (this._isReconnect && this.seqBySession.size > 0) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: Race condition: doPost('reconnect', ...) is fire-and-forget (not awaited), but flushPendingSends() on line 234 runs immediately after. If there are queued sends, they race the reconnect POST to the server. If a send arrives before the reconnect, the server hasn't reattached the session yet — events may be lost or misordered. The reconnect POST should be awaited before flushing pending sends. [fixable]

Comment thread server/worktree.ts Outdated
// Skip secondary repos that resolve to the same path as the primary —
// otherwise both "primary" and "mgmt" map to the same worktree, and
// cleanupSessionWorktrees removes the primary thinking it's a secondary.
if (repoPath === primaryRepo) continue;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: Path deduplication uses strict string equality (repoPath === primaryRepo) with no normalization. loadRepoConfig in repo-config.ts stores paths as raw strings from .mitzo.json. If the primary and secondary differ by trailing slash, symlink, or relative vs absolute form, the guard fails and both entries are added. Consider using path.resolve() or fs.realpathSync() before comparison. [fixable]

Comment thread server/chat.ts Outdated
// Guard: never remove a secondary whose path matches the primary worktree.
// This can happen when the base repo is also listed in .mitzo.json repos
// (e.g. "mgmt"), causing discoverSessionWorktrees to add both entries.
if (primaryPath && path === primaryPath) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: Same string-equality issue as discoverSessionWorktrees: path === primaryPath compares worktree paths constructed via join(repoPath, ...). If the repo paths weren't normalized at discovery time, this guard also fails. However, since both paths are constructed with join() from the same source, the cleanup guard will match whenever the discovery guard matches — so this is consistent, just not robust against symlinks or non-canonical paths. [fixable]

Comment thread server/chat.ts
const primaryPath = session.worktreePaths.get('primary')?.path;
for (const [repoName, { wtId, path }] of session.worktreePaths) {
if (repoName === 'primary') continue;
const repoPath = config.repos[repoName];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The cleanupSessionWorktrees guard is defense-in-depth against duplicates that discoverSessionWorktrees should have already filtered. The comment explains why, which is good. But this means there are two dedup mechanisms that must stay in sync. Consider extracting the path normalization into a shared helper so both call sites use identical logic. [fixable]


it('includes sessions in reconnect URL', () => {
const conn = new SseConnection(createConfig());
it('sends reconnect POST on welcome when has tracked sessions', () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: No test verifies the ordering contract: reconnect POST must complete before pending sends are flushed. A test where mockFetch delays the reconnect response and verifies sends aren't dispatched until after would catch the race condition identified above. [fixable]

}),
);
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: No test covers what happens when the reconnect POST fails (fetch rejects or returns non-ok). The current code silently swallows the error, but the test suite should verify the client degrades gracefully (e.g., still flushes sends, doesn't throw). [fixable]

…so in repos config

When the base repo (mgmt) is listed in .mitzo.json repos as a secondary,
discoverSessionWorktrees adds both "primary" and "mgmt" entries pointing
to the same worktree. On session cleanup, cleanupSessionWorktrees skips
"primary" but removes "mgmt" — destroying the primary worktree and
breaking resume with "Path does not exist" errors.

Fix at two layers:
- discoverSessionWorktrees: skip secondary repos matching primaryRepo
- cleanupSessionWorktrees: guard against removing secondaries whose path
  matches the primary worktree (defense in depth)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@dimakis dimakis force-pushed the fix/worktree-primary-dedup branch from 84e1e31 to 8b92974 Compare June 20, 2026 11:20
@dimakis

dimakis commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 3 issue(s) (1 warning).

server/worktree.ts

Correct fix for a real bug (primary worktree deletion). Defense-in-depth at both discover and cleanup layers is good. Main concern is that string equality may miss symlink/trailing-slash path variants — consider normalizing paths.

  • 🟡 unsafe_assumptions (L243): String comparison repoPath === primaryRepo doesn't handle symlinks or trailing-slash differences. If a user configures .mitzo.json repos with a symlinked path (e.g. ~/tools/mitzo/Users/x/tools/mitzo) while REPO_PATH uses the resolved path (or vice versa), the dedup fails silently and the original bug reappears. Consider using realpathSync or path.resolve for normalization. The same applies to the path === primaryPath guard in chat.ts:1271. [fixable]
  • 🔵 bugs (L316): createSessionWorktrees (worktree.ts:303-329) iterates secondaryRepos without the same dedup guard added to discoverSessionWorktrees. If both the primary and a secondary resolve to the same repo, createWorktree is called twice — it doesn't crash (the second call reuses the existing worktree), but the returned results map will contain a duplicate entry that could propagate into worktreePaths. The fix is correct at the discover and cleanup layers, but adding the same if (repoPath === primaryRepo) continue here would make all three functions consistent. [fixable]

server/chat.ts

Correct fix for a real bug (primary worktree deletion). Defense-in-depth at both discover and cleanup layers is good. Main concern is that string equality may miss symlink/trailing-slash path variants — consider normalizing paths.

  • 🔵 style (L1268): The 3-line comment on the guard duplicates the comment already present in discoverSessionWorktrees (worktree.ts:240-242). Since the discover dedup is the primary fix and this guard is a defense-in-depth fallback, a single line like // Defense-in-depth: see discoverSessionWorktrees dedup would reduce duplication. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s) (1 warning).

server/worktree.ts

Solid defense-in-depth fix at two layers (prevention + safety net), but the string-equality path comparison is fragile — should use path.resolve() to handle trailing slashes, symlinks, and relative segments.

  • 🟡 unsafe_assumptions (L243): String equality (repoPath === primaryRepo) is fragile — paths from different sources (.mitzo.json vs REPO_PATH env var) may differ by trailing slash, symlink, or relative component (e.g. /repo vs /repo/ vs /home/user/../repo). Use resolve(repoPath) === resolve(primaryRepo) (from path) for canonical comparison. The same applies to path === primaryPath in chat.ts:1271. Since repo-config.ts stores paths as-is without normalization, the mismatch risk is real. [fixable]

server/__tests__/worktree.test.ts

Solid defense-in-depth fix at two layers (prevention + safety net), but the string-equality path comparison is fragile — should use path.resolve() to handle trailing slashes, symlinks, and relative segments.

  • 🔵 missing_tests (L602): The test only covers the exact-string-match dedup case. A test with a trailing-slash variant (e.g. mgmt: primaryRepo + '/') would document the current limitation or validate a resolve()-based fix. [fixable]

server/chat.ts

Solid defense-in-depth fix at two layers (prevention + safety net), but the string-equality path comparison is fragile — should use path.resolve() to handle trailing slashes, symlinks, and relative segments.

  • 🔵 style (L1268): The 3-line comment block explaining the guard is justified (non-obvious invariant), but the log message at line 1272 duplicates the same explanation. Consider shortening the comment to just the first line since the log message already serves as documentation for runtime debugging. [fixable]

Comment thread server/worktree.ts Outdated
// Skip secondary repos that resolve to the same path as the primary —
// otherwise both "primary" and "mgmt" map to the same worktree, and
// cleanupSessionWorktrees removes the primary thinking it's a secondary.
if (repoPath === primaryRepo) continue;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: String equality (repoPath === primaryRepo) is fragile — paths from different sources (.mitzo.json vs REPO_PATH env var) may differ by trailing slash, symlink, or relative component (e.g. /repo vs /repo/ vs /home/user/../repo). Use resolve(repoPath) === resolve(primaryRepo) (from path) for canonical comparison. The same applies to path === primaryPath in chat.ts:1271. Since repo-config.ts stores paths as-is without normalization, the mismatch risk is real. [fixable]

expect(result.has('secondary')).toBe(false);
});

it('deduplicates secondary repos that match primaryRepo', () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: The test only covers the exact-string-match dedup case. A test with a trailing-slash variant (e.g. mgmt: primaryRepo + '/') would document the current limitation or validate a resolve()-based fix. [fixable]

Comment thread server/chat.ts
if (repoName === 'primary') continue;
const repoPath = config.repos[repoName];
if (!repoPath) continue;
// Guard: never remove a secondary whose path matches the primary worktree.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The 3-line comment block explaining the guard is justified (non-obvious invariant), but the log message at line 1272 duplicates the same explanation. Consider shortening the comment to just the first line since the log message already serves as documentation for runtime debugging. [fixable]

Address Centaur review: string equality could miss trailing slashes,
symlinks, or relative vs absolute paths. Use path.resolve() in both
discoverSessionWorktrees and cleanupSessionWorktrees guards.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@dimakis

dimakis commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 3 issue(s) (1 warning).

server/worktree.ts

Correct fix for the primary-worktree-deletion bug with good test coverage, but createSessionWorktrees in worktree.ts has the same missing dedup guard that was fixed in discoverSessionWorktrees — worth closing that gap in this PR.

  • 🟡 bugs (L316): The same dedup guard added to discoverSessionWorktrees (line 243) is missing from createSessionWorktrees (line 316). When the primary repo is also listed in .mitzo.json repos, this function will attempt to createWorktree for the same repo twice — the second call may fail (branch already exists) or silently create a redundant entry in the results map that reintroduces the double-mapping this PR aims to prevent. Adding if (resolve(repoPath) === resolve(primaryRepo)) continue; in the secondary loop would close the gap. [fixable]

server/__tests__/worktree.test.ts

Correct fix for the primary-worktree-deletion bug with good test coverage, but createSessionWorktrees in worktree.ts has the same missing dedup guard that was fixed in discoverSessionWorktrees — worth closing that gap in this PR.

  • 🔵 missing_tests (L602): The discoverSessionWorktrees dedup test passes identical string paths (primaryRepo for both). Consider adding a case where the paths differ only in normalization (e.g., trailing slash or ./ prefix) to verify that resolve() is actually doing useful work, rather than just string equality. [fixable]

server/chat.ts

Correct fix for the primary-worktree-deletion bug with good test coverage, but createSessionWorktrees in worktree.ts has the same missing dedup guard that was fixed in discoverSessionWorktrees — worth closing that gap in this PR.

  • 🔵 style (L1268): The 3-line comment block (lines 1268-1270) explaining the guard repeats the same information already in the discoverSessionWorktrees comment. A single line like // Dedup: secondary may alias primary (see discoverSessionWorktrees) would suffice as defense-in-depth commentary. [fixable]

Address second Centaur review: add test validating resolve()-based
dedup handles trailing slash differences, shorten redundant comment.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@dimakis

dimakis commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 2 issue(s).

server/worktree.ts

Solid targeted fix with good test coverage. One gap: the same dedup guard should also be applied to createSessionWorktrees in worktree.ts (called from the external hooks endpoint) for consistency.

  • 🔵 missing_tests (L316): createSessionWorktrees in worktree.ts (called from app.ts external hooks endpoint) iterates secondaryRepos without the same resolve() dedup guard added to discoverSessionWorktrees. If a secondary repo resolves to the same path as the primary, the returned results map will contain a duplicate entry. The cleanup guard in chat.ts would prevent data loss, but adding the dedup here too would be defense-in-depth and keep the three codepaths consistent. [fixable]
  • 🔵 style (L240): The 3-line comment on the dedup guard is useful for the first occurrence but duplicates context already captured in the commit message. A single line like // Dedup: secondary that resolves to primaryRepo would shadow cleanup. would suffice. [fixable]

@dimakis dimakis merged commit d5587a2 into main Jun 20, 2026
1 check passed
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