Skip to content

[DEV-90] Isolate CLI task execution in git worktrees to protect the user's current work - #71

Open
danii1 wants to merge 2 commits into
mainfrom
feature/dev-90
Open

[DEV-90] Isolate CLI task execution in git worktrees to protect the user's current work#71
danii1 wants to merge 2 commits into
mainfrom
feature/dev-90

Conversation

@danii1

@danii1 danii1 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Task: DEV-90

Summary: Isolate CLI task execution in git worktrees to protect the user's current work

Implementation Details

I'll start by exploring the codebase to understand the structure and find existing worktree-related logic.
The exploration revealed two existing worktree implementations. Now let me read the key files directly to ground the implementation.
Now let me read the main entry point sections — the CLI options, main(), and the per-task processing flow.
Now let me read processSingleTask and the surrounding helpers:
Let me read the key Utils functions in utils.ts:
I have a good picture now. Let me verify a few remaining details — config dir walk-up semantics, the test git-state guard, docs layout, and a few Utils signatures:
Important discovery: findConfigDir stops at the first .git entry — so from inside ANY worktree, config resolution falls back to <worktree>/.devintern-code. Let me check which durable-state loaders resolve lazily from cwd, to pick the right integration strategy:
The queue-db env override (WEBHOOK_QUEUE_DB) is key — all durable stores route through it. Now let me check loadProjectSettings/loadEnvironment in index.ts:
loadProjectSettings reads directly from process.cwd() — confirming the symlink approach is needed. Let me check the docs layout and test runner setup before implementing:
All research done. Now I have the complete picture. Let me set up my task list and implement.
Now let me fix two issues I introduced — the undefined phaseActiveForGuard and the fragile mkdir path in ensureStateDirIgnored:
Module created. Now wiring into index.ts — first the import and CLI option:
Now the main() pull-vs-fetch change (a pull would move the user's branch; when isolating we only fetch — the worktree branches from the fetched refs):
Now the task loop — enter isolation per task, with explicit finish calls on every path (the catch body may call process.exit(), which would skip a finally):
Let me review the loop I just wrote — I need to fix a dropped results.successful++ and simplify:
Now hook cleanup into gracefulShutdown (runs before tracker I/O so teardown is fast and can't be skipped by the 15s exit timer):
Two fixes needed: wire the commander flag (--no-worktree-isolation) at both call sites, and drop the unused import:
Both call sites wired. Now the test suite:
A few fixes needed in the test file (clone path, a helper for failing git commands), plus a module improvement — don't auto-commit on a detached HEAD during interrupt preservation (the commit would be unreachable; prefer the patch):
Now the module fix — reuse the captured state and skip commits on detached HEAD:
Now let's run the new tests to validate the implementation:
All 12 tests pass with the full lifecycle verified. Now typecheck, lint, and the full suite:
The on overload works but removeListener hits a bun-types quirk. Cleaner fix anyway: install the exit guard once permanently — it self-no-ops when no isolation is active, so removal isn't needed:
Now remove the two removeExitGuard() call sites:
Typecheck clean. Now lint and format:
One lint warning is mine (lstatSync unused in the module). Removing it:
Now the full test suite for the package to check for regressions:
All 897 tests pass, 0 failures. Now the documentation:
Now update usage.md — flag examples and a pointer section:
Now configuration.md — add the environment variables after the Output Directory section:
Now the CLAUDE.md architecture note:
Final verification — format, lint, typecheck, plus a CLI --help smoke test for the new flag:
The opt-out flag correctly triggered legacy pull behavior. Let me verify default (isolation on) uses fetch-only sync:
Excellent — fetch-only sync works by default. Now a full end-to-end smoke test with a stub agent to exercise the real CLI wiring through success and failure paths:
End-to-end flow works. Note the isolation correctly anchored at the invocation cwd's repo (the monorepo, since that's where I ran the CLI). Let me verify the smoke repo is untouched and clean up test residue:
Let me verify the smoke repo state cleanly and clean up the monorepo residue from the smoke test:
Smoke repo is pristine. Cleaning monorepo residue from the smoke run:
All clean. Final verification round — full tests, typecheck, lint, and a diff review:
Diff is clean and minimal. Let me do a final review pass of the complete module:
Everything is verified. Completing the task list:
DEV-90 is implemented. Summary of what was built:

Implementation

New module: packages/code/src/lib/worktree-isolation.ts — task runs now execute inside a disposable git worktree at <repo>/.devintern-code/worktrees/<task>-<pid>-<ts> instead of the user's checkout. It extends the codebase's existing worktree patterns rather than reinventing them: the repo-manager/review-worktree teardown ladder (git worktree remove --forcermSyncprune), the pipeline's own createFeatureBranch (runs safely in the throwaway tree, including all its worktree-conflict handling), and the fleet mode's create→run→remove lifecycle.

Key behaviors, mapped to acceptance criteria:

  • Isolation — process cwd moves into a detached worktree based on origin/<target> (fallbacks: local branch, HEAD); the user's uncommitted changes, staged files, index, and branch are never touched. A .devintern-code symlink back to the shared state dir plus pinned WEBHOOK_QUEUE_DB keep config/settings/queue.db resolving correctly (config discovery stops at the worktree's .git). Startup sync switches from pull to fetch-only so the user's branch isn't fast-forwarded.
  • Results preserved — uncommitted worktree changes are committed to the task's feature branch before removal (feat: on success, wip(devintern): on failure/interrupt; commits survive worktree removal), with a binary-safe patch fallback (worktree-changes.patch in the output dir). --no-auto-commit gets patch-only preservation.
  • Teardown on every path — normal completion/failure, process.exit() (exit-event guard), SIGINT/SIGTERM (hooked into gracefulShutdown before tracker I/O), and hard kills via pid-liveness orphan sweep with a 7-day age backstop. Teardown is fully synchronous and best-effort.
  • Non-git dirs — graceful fallback with a clear notice; opt-outs: --no-worktree-isolation, DEVINTERN_NO_WORKTREE_ISOLATION=1, DEVINTERN_TASK_WORKTREE_DIR.

Wiring (src/index.ts, +76 lines): CLI flag, per-task enter/finish around processSingleTask with explicit finish calls on exiting paths, shutdown hook.

Verification

  • 12 new tests + 885 pre-existing: 897 pass, 0 fail; typecheck, oxlint, oxfmt clean
  • End-to-end smoke test against a real dirty repo confirmed: worktree created, user's tree byte-identical afterward, results on feature/<key>, zero leftover registrations

This PR was automatically created by @devintern/code

devintern-internal[bot] added 2 commits August 26, 2026 14:39
Address all three worktree-isolation review items (iteration 1):

1. [HIGH] Silent data loss on preservation failure: finish() now tracks
   whether the tree was dirty at teardown entry and, when dirty changes
   could be neither committed nor patched, prints a loud warning naming
   the worktree path before removal and never prints 'Your working
   directory was not modified'. writePatchSnapshot() also retries with a
   plain 'git diff HEAD --binary' when staging/index reads fail (index
   lock, non-zero diff exit), recovering tracked changes that the
   staged path would previously drop.

2. [MEDIUM] Performance: resolveBaseRef() is now memoized per process
   via resolveBaseRefCached(), keyed by repo root + target branch. Batch
   mode performs one origin fetch per branch instead of one per task;
   concurrent entries share the in-flight resolution and rejections
   evict the cache entry for retry.

3. [MEDIUM] Test coverage: added tests for commit-hook refusal falling
   back to --no-verify; detached-HEAD teardown patching instead of
   committing; resolveBaseRef fallback chain (origin missing -> local
   branch -> HEAD); assertion that .devintern-code never lands in the
   preserved commit or patch; the process.exit()/exit-guard teardown
   path (via subprocess); and the new unsalvageable-data-loss warning.

Address both worktree-isolation review items (iteration 2):

4. [MEDIUM] Inconsistent entry-path failure handling: the batch loop now
   wraps enterTaskWorktreeIsolation() in try/catch, so a throwing entry
   (unwritable DEVINTERN_TASK_WORKTREE_DIR mkdir, rejecting
   resolveBaseRefCached fetch) degrades to an in-place run with the
   standard 'Could not isolate' warning instead of aborting all
   remaining batch tasks.

5. [MEDIUM] Unsalvageable dirty trees are no longer destroyed: when
   commit and patch preservation both fail, finish() skips teardown and
   renames the directory to '<name>.unsaved' (outside the parsed
   worktree-name pattern so the orphan sweep cannot reap it), prunes
   only the stale registration, prints the recovery path, and reports
   'kept for manual recovery' without claiming results live on a
   branch. Test updated to assert the salvaged copy survives intact.

Address all three worktree-isolation review items (iteration 3):

6. [HIGH] Failed 'git status' no longer treated as clean: dirty is now
   '!success || output.length > 0', so status failures (transient
   index.lock contention, fs errors) route into commit/patch
   preservation and, worst case, the '.unsaved' rename instead of
   teardown deleting uncommitted agent work.

7. [MEDIUM] Windows-safe absolute-path detection in resolveFrom():
   uses path.isAbsolute() instead of a leading-slash check, so
   'rev-parse --git-path' drive-letter results (C:/...) on Windows are
   honored and .devintern-code exclude registration works there.

8. [MEDIUM] Failure-path test coverage: new tests assert (a) a failing
   git status (corrupt linked-worktree index) keeps the tree as
   '<name>.unsaved' with contents intact and warns loudly; (b)
   .git/info/exclude gains exactly one .devintern-code entry across
   repeated enters; (c) an injectable symlink dep that throws engages
   the settings-copy fallback (real directory + copied settings.json,
   pinned WEBHOOK_QUEUE_DB) and teardown still removes it safely.

Address both worktree-isolation review items (iteration 4):

9. [MEDIUM] State symlink can no longer leak into preservation commits:
   finish() removes the .devintern-code link (and the settings-copy
   fallback directory; rmSync never traverses symlinks) BEFORE
   captureWorktreeState()/preserveWorktreeChanges() run. The agent run
   has already ended, so config continuity is unneeded, and when the
   .git/info/exclude registration failed the lingering link previously
   showed up as untracked in 'git status --porcelain' (always-dirty)
   and got staged by 'git add -A', committing a machine-specific
   absolute symlink onto the feature branch.

10. [MEDIUM] Test coverage: (a) a read-only .git/info/exclude
    (chmod 0o444) simulates silent ensureStateDirIgnored failure and
    asserts the state link never appears in the preservation commit nor
    triggers phantom-dirty salvage handling; (b) utimesSync-backdated
    orphan dirs owned by a live pid are swept via the ORPHAN_MAX_AGE_MS
    age backstop while fresh live-pid entries survive; (c) a spy on
    Utils.executeGitCommand proves consecutive batch entries trigger
    exactly one origin fetch, and that a rejected resolution evicts the
    baseRefCache entry so the next task retries instead of replaying
    the cached failure.

Address all three worktree-isolation review items (iteration 5):

11. [MEDIUM] First-run queue.db pin: linkSharedConfigDir() now assigns
    WEBHOOK_QUEUE_DB before the shared-dir existence check, so on a
    genuinely first run (state dir not yet created) lazy queue.db
    initialization resolves through the pin and creates the database
    under the repo root (lazy writers mkdir missing parents) instead of
    inside the disposable worktree, where teardown previously destroyed
    the run records/retry bookkeeping.

12. [MEDIUM] Orphan sweep no longer destroys uncommitted work:
    sweepOrphanedTaskWorktrees() runs captureWorktreeState() before
    teardown; stale entries holding uncommitted changes (a failed
    status counts as unknown-dirty) are renamed to '<name>.unsaved' —
    outside the name-pattern check so future sweeps cannot reap them —
    with only the registration pruned, mirroring finish()'s salvage
    path. Only clean stale entries are removed outright. Tests cover a
    real crashed dirty worktree being salvaged intact and a clean one
    still being torn down.

13. [MEDIUM] Test coverage for isWorktreeIsolationActive() and CLI
    wiring: unit tests cover git disabled, opt-out env values
    1/true/yes/on case-insensitive (with fall-through for
    0/false/no/off/empty), the marker-env nesting guard, and non-git
    cwd returning false. End-to-end subprocess tests against src/
    index.ts assert the startup sync fetches (never pulls) when
    isolation will engage, and that --no-worktree-isolation and
    DEVINTERN_NO_WORKTREE_ISOLATION each route back to the pull path.
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