Documentation
¶
Overview ¶
Package forker implements the default tool.EnvironmentForker used by fork-join parallelism (harness pattern 8). It isolates a forked child agent loop from the shared base working tree so parallel children cannot race on, or mutate, the base.
Isolation strategy (chosen per base at Fork time):
Git worktree — when the base workspace root is inside a git repository, Fork runs `git worktree add --detach <child> HEAD` so the child gets a real, independent checkout of HEAD that shares the object store but has its own index and working tree. cleanup runs `git worktree remove --force` and deletes the directory. This is the cheapest correct isolation for a repo: it copies no file contents.
Recursive copy — when the base root is NOT a git repo (no `.git`), OR when the Forker was constructed WithForceCopy, Fork recursively copies the whole base tree into a fresh temp directory. cleanup removes that directory. This is a full, independent copy: writes in the child never touch the base. When the base IS a git repo, the copy INCLUDES its `.git` directory, so the fork is an independent repository with its OWN object database and refs.
Isolation guarantees: a child Workspace returned by Fork is rooted at an isolated directory; Write/Edit/Shell through the child affect ONLY that directory. The base tree is never written. cleanup is idempotent-friendly (it tolerates an already-removed child) and must be called when the child is done.
Worktree vs. full-copy isolation — the tradeoff:
The git-worktree path isolates the WORKING TREE and INDEX but SHARES the object database and refs. Forked children CAN mutate their working tree: Edit/Write land in the fork, and Shell is workspace-aware (its CommandRunner runs with the forked child's Workspace.Root() as the working directory; see app.buildParallelChildEngine / buildMemberEngine and internal/adapter/tools/bash.go), so a child's Shell — and any git it runs — defaults to the fork's working tree, not the parent base. But in a worktree, a child that runs `git commit` / `git push` / `git update-ref` via Shell writes objects and refs into the SHARED `.git`, escaping isolation. That is the inherent git-worktree model.
To close that gap for MUTATING forks, construct the Forker WithForceCopy: it forces the recursive-copy path even for a git repo and copies the `.git` directory along with the tree, so the fork is a SELF-CONTAINED repository. A branch's git/Shell writes (commits, refs, objects) then stay inside the fork and CANNOT reach the base repo. The composition root wires WithForceCopy for the Fork tool's branches and for mutating team members (see internal/app/build.go).
The DEFAULT (no option) — the cheap auto worktree-vs-copy behaviour — is now used DELIBERATELY for READ-ONLY callers that nonetheless need a shell, specifically read-only team members (see internal/app.buildTeamWiring): a worktree SHARES the base repo's `.git`, so the member gets the full commit history for `git log`/`git show` inspection at near-zero cost, while its own working tree + index keep its (non-mutating) Shell from disturbing the base working tree. Because a worktree shares `.git/config` + `.git/hooks`, the composition root runs such a member's Shell through a SANDBOXED command runner that neutralises git config-driven code execution (core.pager / core.hooksPath / core.fsmonitor / external diff); see internal/app.buildSandboxedCommandRunner.
The forker's OWN git invocations are hardened the same way. `git worktree add` fires the base repo's post-checkout hook, and even `git rev-parse` honours core.pager / external diff — all at FORK time, BEFORE the sandboxed member runner exists. So runGit/gitRepoRoot set cmd.Env = gitenv.Scrub(os.Environ()), the SAME neutralizing environment the member runner uses (the single shared source in internal/adapter/gitenv keeps the two from drifting): inherited GIT_* danger is dropped and hooks/pager/fsmonitor/external-diff are force-neutralised.
Cost: a full copy (including `.git`, which for an established repo is often the bulk of the bytes) is HEAVIER than a worktree, which copies no file contents. That is exactly why worktree is the default. For mutating branches the stronger isolation is worth the extra copy.
Dirty-aware overlay (WithDirtyOverlay) — the read-only-child gap:
A plain `git worktree add --detach HEAD` checks out the COMMITTED HEAD, so the child sees a CLEAN tree: Read/Grep/Glob AND the child's `git status`/`git diff` report no changes even when the operator has uncommitted, staged, or untracked work in the parent. A read-only explorer asked to review the operator's in-progress changes would then find nothing — it cannot see what the operator sees. WithDirtyOverlay is the worktree-only MODE that closes that gap by mirroring the parent's uncommitted state into the fresh worktree. It is best-effort: a failed overlay resets the worktree to a pristine-HEAD checkout (the safe floor) AND returns a degraded-fork advisory from Fork so the agent can tell the child it is seeing committed HEAD only — the failure case is SURFACED, never silent. It is inert on the force-copy path (copyTree already carries the parent's dirty state verbatim). The full step-by-step mechanics live on WithDirtyOverlay's godoc.
The copy path is bounded only by available disk and the size of the base tree; it copies regular files and directories and SKIPS symlinks (so a symlink cannot smuggle the copy outside the base). Neither path auto-merges results back — see the ParallelTool docs (no-auto-merge boundary).
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Forker ¶
type Forker struct {
// contains filtered or unexported fields
}
Forker is the default tool.EnvironmentForker. It picks git-worktree isolation when the base root is a git repo and falls back to a recursive directory copy otherwise. It is safe for concurrent use: Fork holds no per-call state on the Forker, and each call derives a uniquely-named child directory.
func New ¶
New constructs the default Forker. newWorkspace builds a child tool.Workspace over an isolated directory (the composition root passes osfs.NewWorkspace); it must be non-nil.
func (*Forker) Fork ¶
func (f *Forker) Fork(ctx context.Context, base tool.Environment, label string) (tool.Environment, func() error, string, error)
Fork creates an isolated child workspace derived from base. It uses a git worktree when base's root is a git repo, else a recursive copy. The returned cleanup removes the child's backing storage (worktree or copy).
The advisory is non-empty ONLY on the dirty-overlay worktree path when the overlay DEGRADED (the base was dirty but its uncommitted state could not be mirrored into the child, so the child sees committed HEAD only). Every other path — force-copy, plain worktree, copy fallback, clean tree, no overlay — returns "".
type Merger ¶
type Merger struct{}
Merger is the tool.EnvironmentMerger implementation: it merges a preserved winning fork's working-tree changes BACK into the parent workspace. It is the composition-owned merge half of BOTH merge-back paths — a Parallel single-branch winner (ADR 0039) AND a writable Subagent (mode:"read-write", ADR 0040) — DEFAULT-ON with no flag, the capability that lets a delegated implementer's edits actually land without a manual copy/merge step. The composition root wraps it in a SerializingMerger (one process-wide mutex) so concurrent merges from different runs/sessions cannot interleave their writes.
Mechanism (mirrors overlayDirty, in the reverse direction — fork → parent):
- (1) Cheap clean probe on the fork: a clean fork short-circuits (nothing to merge), returning nil without touching the parent.
- (2) Tracked + staged + deletions: pipe `git diff --no-ext-diff --binary HEAD` from the fork into `git apply --whitespace=nowarn -` in the parent. --binary round-trips binary files; --no-ext-diff + the scrubbed env keeps an attacker-named external diff driver from firing.
- (3) Untracked, non-ignored files in the fork: `git ls-files --others --exclude-standard -z`, then copy each into the parent, skipping symlinks/irregular files (same discipline as copyTree/overlayDirtyInner — a symlink could point outside the fork).
On ANY failure (diff/apply rejected, copy failed) it returns a non-nil error naming the fork path so the operator can resolve manually. It NEVER forces: a partial merge is worse than none. The fork is left intact (the caller still owns its cleanup / reaper slot) so a failed merge is recoverable. The merge runs in the PARENT workspace under the parent's trust posture (the same trust the parent's own Edit/Write carries) — applying a diff is a parent-side operation, not a fork-side one.
Every git invocation carries the SAME scrubbed/neutralizing env as runGit / overlayDirty (gitenv.Scrub(envscrub.Scrub(os.Environ()))), so a shared or copied `.git/config` and attacker-named drivers cannot drive code here.
type Option ¶
type Option func(*Forker)
Option configures a Forker.
func WithDirtyOverlay ¶
func WithDirtyOverlay() Option
WithDirtyOverlay makes Fork mirror the parent's UNCOMMITTED state into a freshly created git worktree, closing the "a read-only child sees a clean tree" gap. A plain `git worktree add --detach HEAD` checks out the committed HEAD, so a read-only explorer (Subagent or read-only team member) cannot see the operator's in-progress work — modified tracked files, staged changes, deletions, or new untracked files. With this option set, after the worktree is created the forker:
- applies `git diff --no-ext-diff --binary HEAD` from the parent (tracked modifications + staged changes + deletions; --binary so binary files round-trip) onto the child via `git apply`; and
- copies each untracked, non-ignored file (`git ls-files --others --exclude-standard`) into the child, skipping symlinks/irregular files.
It is .gitignore-respecting, symlink-skipping, BEST-EFFORT, and a NO-OP on a clean tree (a `git status --porcelain` probe short-circuits before any diff/apply, keeping the common cheap path free).
SURFACED degradation: best-effort does NOT mean silent. When the tree was dirty but the overlay could not be applied (e.g. `git apply` rejected the patch), the forker resets the worktree to a pristine-HEAD checkout (a partially-applied overlay is worse than none — the clean worktree is the safe floor) AND Fork returns a non-empty degraded-fork advisory describing that the child is seeing committed HEAD only. The agent surfaces that advisory to the child so a read-only explorer does not silently conclude "nothing changed" while the operator has uncommitted work. On a clean tree or a successful overlay the advisory is empty.
WithDirtyOverlay applies ONLY on the worktree branch. On the force-copy path (WithForceCopy), the recursive copyTree already carries the parent's dirty state verbatim, so combining the two is harmless: force-copy wins by call site and the overlay never runs. It is the mode the composition root wires for read-only children that need a shell (the Subagent worktree forker and the read-only team member forker).
func WithForceCopy ¶
func WithForceCopy() Option
WithForceCopy forces FULL isolation: Fork always takes the recursive-copy path (copying the base tree INCLUDING its `.git` when present) instead of a git worktree, even when the base is a git repo. The fork is then a self-contained repository with its own object database and refs, so a child branch's git/Shell writes (commits, refs, objects, working-tree edits) CANNOT reach the base repo.
This is the mode for MUTATING forks (the Parallel tool's branches and mutating team members), where isolation matters more than speed: a full copy — `.git` and all — is heavier than a worktree (which copies no file contents), which is why the default leaves the cheaper auto worktree-vs-copy behaviour in place. Symlinks are still skipped, so the copy cannot be smuggled outside the base.
func WithRunner ¶
func WithRunner(r childRunner) Option
WithRunner injects the bound-command-runner builder the forker uses to mint a child tool.CommandRunner for each forked directory (issue #462). The runner is bound to the child root, so a forked child's Shell observes the SAME child namespace its Read/Write do. nil (or unset) means forked children are shell-less (the composition root wires the builder only when Shell is on). The builder is called once per Fork with the isolated child directory; it returns nil when the child namespace should have no shell (e.g. the trust gate withheld it).
func WithTempBase ¶
WithTempBase sets the parent directory under which isolated child directories are created (default: the OS temp dir). Useful to keep forks on the same filesystem as the base for cheaper copies, or to scope them to a test dir.
type SerializingMerger ¶
type SerializingMerger struct {
// contains filtered or unexported fields
}
SerializingMerger wraps an inner tool.EnvironmentMerger with a single mutex so that concurrent Merge calls are SERIALIZED — at most one fork's working-tree diff is applied to a parent workspace at a time, process-wide.
Why it exists: a merge applies a fork's `git diff HEAD` into a PARENT workspace (a write of arbitrary files). Parallel's single-branch auto-merge drives merges, and a process may run many sessions concurrently. Without serialization, two merges targeting the SAME parent workspace (or two merges sharing any on-disk state the inner merger touches) could interleave their `git apply` / file-copy writes and corrupt the parent tree. The mutex makes merge-back a process-wide critical section: correctness over throughput, which is the right call for a write that is already a post-run, off-the-hot-path step.
The decorator is composition-owned: ONE instance is built in Phase A (like the fork reaper / shared MCP manager) and injected — as a tool.EnvironmentMerger — into the Parallel path (WithAutoMerge), so the SAME mutex serializes across every merge in the process. A per-session instance would NOT serialize across sessions, defeating the point. It owns its own sync.Mutex (zero-value-ready) and forwards the inner result/error verbatim.
func NewSerializingMerger ¶
func NewSerializingMerger(inner tool.EnvironmentMerger) *SerializingMerger
NewSerializingMerger wraps inner so its Merge calls are serialized process-wide by a single mutex the returned decorator owns. Construct ONE instance in composition and share it across every merge-driving tool.
func (*SerializingMerger) Merge ¶
func (m *SerializingMerger) Merge(ctx context.Context, child, parent tool.Environment) error
Merge implements tool.EnvironmentMerger: it takes the decorator's mutex for the whole duration of the inner Merge, so at most one merge runs at a time, then returns the inner result verbatim.