Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/code/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,18 @@ Everything in the output directory is a debug artifact and safe to delete. Retry
└── attachments/ # Jira attachments
```

## Worktree Isolation

Task runs execute in a disposable git worktree so your current work is never modified. See the [Worktree Isolation](./worktree-isolation.md) guide for behavior and lifecycle.

```bash
# Opt out and run directly in your working directory (or use --no-worktree-isolation)
DEVINTERN_NO_WORKTREE_ISOLATION=1

# Store isolated worktrees somewhere else (default: <repo>/.devintern-code/worktrees)
# DEVINTERN_TASK_WORKTREE_DIR=/path/for/worktrees
```

## Agent Harness

Configure which AI agent runs and how long it can work:
Expand Down
17 changes: 17 additions & 0 deletions docs/code/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ devintern TASK-123 --max-turns 1000
# Skip automatic commit after AI agent completes
devintern TASK-123 --no-auto-commit

# Run directly in the current directory instead of an isolated git worktree
devintern TASK-123 --no-worktree-isolation

# Create pull request after implementation
devintern TASK-123 --create-pr

Expand All @@ -64,6 +67,20 @@ devintern TASK-123 --skip-clarity-check
devintern TASK-123 --force
```

## Isolated Task Worktrees

By default, each task runs in a disposable git worktree so your uncommitted changes, staged files, and current branch are never touched. Results land on the task's `feature/<task-key>` branch; the worktree is removed automatically on success, failure, and interruption.

```bash
# Default: isolated (recommended)
devintern TASK-123

# Legacy behavior: run directly in your working directory
devintern TASK-123 --no-worktree-isolation
```

See [Worktree Isolation](./worktree-isolation.md) for lifecycle details, result access, orphan cleanup, and environment overrides.

## Markdown File Tasks

Pass one or more local `.md` files as arguments. No task tracker credentials are needed:
Expand Down
83 changes: 83 additions & 0 deletions docs/code/worktree-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: "Worktree Isolation"
description: "Task runs execute in a disposable git worktree so your current work is never modified or lost"
section: "Code"
order: 4
dateModified: 2026-08-26
---

# Worktree Isolation

When you run a task in a git repository, devintern executes it inside a **disposable git worktree** instead of your working directory. Your uncommitted changes, staged files, stashes, and current branch are never modified — even though the task pipeline itself runs branch creation and git cleanup steps.

```bash
cd ~/projects/my-app # dirty working tree? doesn't matter
git status # e.g. modified files, staged work, mid-feature branch
devintern MYAPP-456 # runs in .devintern-code/worktrees/devintern-task-myapp-456-<pid>-<ts>
```

During the run you will see:

```
🏝️ Running isolated in worktree: /home/you/projects/my-app/.devintern-code/worktrees/devintern-task-myapp-456-4242-1756...
Based on 'origin/main'. Your working directory stays untouched.
```

## What is protected

| State | Protected how |
| ---------------------------- | -------------------------------------------------------------------------- |
| Uncommitted edits | Never touched; the task operates on a separate checkout |
| Staged files / index | Untouched |
| Current branch | The feature branch for the task is created **in the worktree**, not yours |
| Stashes | Untouched (the legacy in-place flow parks changes in a stash; isolation avoids that entirely) |
| Startup sync | Runs as a fetch-only update of `origin/*` instead of pulling into your branch |

## Where results go

The worktree is removed when the task finishes — successfully or not — but your results survive:

- **Commits live on the task's `feature/<task-key>` branch** in your repository. Commits are stored in the shared git object database, so deleting the worktree keeps them. Check out with:

```bash
git checkout feature/myapp-456
```

- **Uncommitted agent work is committed before removal** (`feat: implement <task-key>` after successful runs, a `wip(devintern): ...` commit after failed/interrupted runs). If a commit is impossible (e.g. failing git hooks), the remaining diff is saved to `{output-dir}/{task-key}/worktree-changes.patch` and printed — apply it with `git apply`.

With `--no-auto-commit`, isolation respects your intent and never creates automatic commits on teardown; uncommitted worktree changes are preserved as the patch file instead.

## Lifecycle

1. **Create** — a detached worktree is added from `origin/<target>` (falls back to the local target branch, then `HEAD`), under `<repo>/.devintern-code/worktrees/<task>-<pid>-<timestamp>`.
2. **Run** — the process moves into the worktree; feature branch creation, the agent run, commits, and optional PR creation all happen there.
3. **Preserve & remove** — pending changes are committed (or patched), then the worktree is removed via `git worktree remove --force` with an `rmSync` fallback and a `prune`. Teardown is best-effort: a cleanup failure never masks the task's actual result.

Cleanup runs on every exit path:

- Normal completion and handled failures (per-task in batch mode too)
- `Ctrl+C` / `SIGTERM` — synchronous cleanup before tracker reporting
- Crashes and internal `process.exit()` calls — via a shutdown guard
- Hard kills (SIGKILL, power loss) — the next run **sweeps orphans**: worktree directories whose embedded creator pid is no longer alive (plus an age backstop) are removed automatically

Concurrent executions are safe: each run gets its own uniquely named directory, and running tasks' worktrees are never swept.

## Configuration discovery

The tool's state directory (`.devintern-code`) belongs to your repository, so each worktree gets a `.devintern-code` symlink back to it. Settings, analytics identity, and durable state (`queue.db`: webhook queue, retry bookkeeping, run records) keep resolving to the real thing. The `.devintern-code` pattern is registered in your repository's local `.git/info/exclude` (never pushed), which hides both the state directory and the worktrees from `git status` and keeps them out of `git add -A`.

## Non-git directories

Running outside a git repository is supported: devintern prints a notice and processes the task directly in the current directory (no branch creation happens anyway). Nothing is created or cleaned up.

## Opting out

| Mechanism | Usage |
| --------------- | --------------------------------------------------------- |
| CLI flag | `--no-worktree-isolation` |
| Environment | `DEVINTERN_NO_WORKTREE_ISOLATION=1` |
| Custom location | `DEVINTERN_TASK_WORKTREE_DIR=/path/for/worktrees` |

Opting out restores the legacy in-place behavior, including its pre-branch stash/clean of your working tree — prefer keeping isolation on.

`--no-git` skips all git features entirely, so isolation is skipped with it.
1 change: 1 addition & 0 deletions packages/code/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Everything under the output directory is a write-only debug artifact. Durable st

- **Runtime**: Bun (required for bun:sqlite in webhook queue)
- **Git branches**: `feature/{task-key-lowercase}` naming convention
- **Task isolation** (`lib/worktree-isolation.ts`): interactive task runs execute in a disposable linked worktree under `.devintern-code/worktrees/<task>-<pid>-<ts>` so the user's checkout is never touched. The process cwd moves into the worktree (injectable `chdir` — tests must never really chdir); a `.devintern-code` symlink back to the repo's state dir plus a pinned `WEBHOOK_QUEUE_DB` keep lazy config/db lookups working despite config discovery stopping at the worktree's `.git`. Teardown preserves uncommitted work as commits on the task branch (patch fallback in the output dir), removes the worktree via git-remove→rmSync→prune, and runs on success/failure/signals (`gracefulShutdown` hook) and `process.exit` (exit-event guard); crashed-run orphans are swept by embedded-pid liveness. Opt out: `--no-worktree-isolation` / `DEVINTERN_NO_WORKTREE_ISOLATION`; location override: `DEVINTERN_TASK_WORKTREE_DIR`
- **Claude execution**: Spawns subprocess with `-p --dangerously-skip-permissions` for implementation; internal analysis-only spawns (clarity check, estimation) currently use the same unattended path (`PREFER_READONLY_ANALYSIS` is off in `lib/analysis-mode.ts` — harness ask/plan modes often return unusable stdout). The read-only prefer + fallback path is kept for re-enable later
- **JIRA integration**: Posts summaries in Atlassian Document Format
- **Webhook isolation**: Sequential queue + branch-scoped worktrees at `/tmp/devintern-review-worktree-<branch>/`
Expand Down
86 changes: 76 additions & 10 deletions packages/code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ import { shouldSkipRetry } from "./lib/retry-gate";
import { formatProcessingFailureMarkdown } from "./lib/trackers/shared/markdown-comment-formatter";
import { parseGitHubPrUrl, recordAgentPrFromUrl } from "./lib/worker-state";
import { Utils } from "./lib/utils";
import {
cleanupActiveWorktreeIsolation,
enterTaskWorktreeIsolation,
isWorktreeIsolationActive,
} from "./lib/worktree-isolation";
import type { WorktreeIsolationHandle } from "./lib/worktree-isolation";
import { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "./lib/git-hook-fixer";
import { runAutoReviewLoop } from "./lib/auto-review-loop";
import { isAutomatedEnvironment } from "./lib/env-detector";
Expand Down Expand Up @@ -169,6 +175,7 @@ interface ProgramOptions {
verbose: boolean;
maxTurns: string;
autoCommit: boolean;
worktreeIsolation: boolean; // --no-worktree-isolation runs in the user's checkout
skipClarityCheck: boolean; // New option to skip clarity check
createPr: boolean; // New option to create pull request
prTargetBranch: string; // Target branch for PR
Expand Down Expand Up @@ -1491,6 +1498,10 @@ program
.option("-v, --verbose", "Verbose output")
.option("--max-turns <number>", "Maximum number of turns for Agent", "500")
.option("--no-auto-commit", "Skip automatic git commit after Agent completes")
.option(
"--no-worktree-isolation",
"Run directly in the current directory instead of an isolated git worktree (your uncommitted changes are then subject to the task's git cleanup)",
)
.option("--skip-clarity-check", "Skip running Agent for clarity assessment")
.option("--create-pr", "Create pull request after implementation")
.option(
Expand Down Expand Up @@ -2417,18 +2428,36 @@ async function main(): Promise<void> {
}
}

console.log("\n📥 Pulling latest changes from remote...");
const pullResult = await Utils.pullLatestChanges(options.prTargetBranch, {
verbose: options.verbose,
});
// With worktree isolation active, tasks never run in this checkout —
// fetch only. A pull here would fast-forward the user's own branch,
// which isolation promises not to touch.
if (await isWorktreeIsolationActive(options.git && options.worktreeIsolation !== false)) {
console.log("\n📥 Fetching latest changes from remote...");
const fetchResult = await Utils.executeGitCommand(["fetch", "origin", "--prune"], {
verbose: options.verbose,
timeoutMs: 60_000,
});

if (pullResult.success) {
console.log(`✅ ${pullResult.message}`);
if (fetchResult.success) {
console.log("✅ Fetched latest changes from remote");
} else {
console.log(`⚠️ Could not fetch from remote: ${fetchResult.error}`);
console.log(" Continuing with locally known state...\n");
}
} else {
// Don't fail the entire workflow if pull fails - just warn the user
console.log(`⚠️ ${pullResult.message}`);
console.log(" Continuing without pulling latest changes...");
console.log(" You may want to pull manually before processing tasks.\n");
console.log("\n📥 Pulling latest changes from remote...");
const pullResult = await Utils.pullLatestChanges(options.prTargetBranch, {
verbose: options.verbose,
});

if (pullResult.success) {
console.log(`✅ ${pullResult.message}`);
} else {
// Don't fail the entire workflow if pull fails - just warn the user
console.log(`⚠️ ${pullResult.message}`);
console.log(" Continuing without pulling latest changes...");
console.log(" You may want to pull manually before processing tasks.\n");
}
}
}

Expand Down Expand Up @@ -2639,15 +2668,46 @@ async function main(): Promise<void> {
for (let i = 0; i < tasksToProcess.length; i++) {
const taskKey = tasksToProcess[i];

// Each task runs in its own disposable worktree so the user's checkout
// (uncommitted changes, staged files, current branch) is never touched.
// Non-git directories and opt-outs return null and run in place; a
// thrown entry (unwritable worktree root, failed base-ref fetch) must
// degrade the same way instead of aborting the remaining batch tasks.
let isolation: WorktreeIsolationHandle | null = null;
if (options.git && options.worktreeIsolation !== false) {
try {
isolation = await enterTaskWorktreeIsolation({
taskKey,
targetBranch: options.prTargetBranch,
autoCommit: options.autoCommit,
patchDir: join(resolveOutputDir(), taskKey.toLowerCase()),
verbose: options.verbose,
});
} catch (error) {
console.warn(
`⚠️ Could not isolate — running in your working directory (${(error as Error).message}).`,
);
console.warn(" Commit or stash to be safe.");
}
}

let succeeded = false;
try {
await processSingleTask(taskKey, i, tasksToProcess.length);
results.successful++;
succeeded = true;

if (i < tasksToProcess.length - 1) {
console.log("\n" + "=".repeat(80));
console.log("⏭️ Moving to next task...\n");
}
} catch (error) {
// Finish explicitly: the handling below may process.exit(), which
// would never reach the cleanup after this catch block. finish() is
// idempotent, so the call after this block stays safe for the
// non-exiting paths too.
isolation?.finish("failed");

// Usage limit is account-global: abort the remaining batch instead of
// hammering tasks that would all fail. Exit 0 so the scheduler retries
// next window without marking the run failed.
Expand All @@ -2671,6 +2731,9 @@ async function main(): Promise<void> {

console.log("⚠️ Continuing with remaining tasks...\n");
}

// No-op when the catch block already finished this isolation.
isolation?.finish(succeeded ? "completed" : "failed");
}

// Print summary for batch operations
Expand Down Expand Up @@ -4462,6 +4525,9 @@ async function gracefulShutdown(signal: "SIGINT" | "SIGTERM", exitCode: number):
if (shuttingDown) return;
shuttingDown = true;
console.log(`\n\n⚠️ Received ${signal}, cleaning up...`);
// Remove the isolated task worktree first (synchronous, local git ops) so a
// slow tracker round-trip can never leave it orphaned behind the exit timer.
cleanupActiveWorktreeIsolation();
// If a task is mid-flight, tell the tracker it was interrupted instead of
// leaving it silently in "In Progress" with no PR and no feedback.
const context = activeTaskContext;
Expand Down
Loading
Loading