Every task gets its own isolated copy of each workspace. Git repositories use git worktrees; non-git directories use snapshot copies. The agent operates in an isolated copy of each repository on a dedicated branch, leaving the main working tree untouched and allowing multiple tasks to run concurrently without interfering with each other.
graph LR
subgraph Main["Main repo (~/projects/myapp)"]
MB["branch: main<br/>working tree: clean"]
end
subgraph Worktree["Task worktree (~/.wallfacer/worktrees/uuid/myapp)"]
TB["branch: task/a1b2c3d4<br/>mounted into container"]
end
Main -.->|"git worktree add"| Worktree
Called by setupWorktrees() in internal/runner/worktree.go when a task enters in_progress. Internally delegates to ensureTaskWorktrees() which is idempotent: if the worktree directory already exists and is a valid git repo, it is reused unchanged (e.g. when resuming a waiting task).
For each configured workspace:
flowchart TD
A["git rev-parse --git-dir<br/>verify path is a git repo"] --> B["git worktree add -b task/uuid8<br/>~/.wallfacer/worktrees/task-uuid/repo-basename"]
B --> C["Store worktree path + branch name<br/>on the Task struct"]
Branch naming uses the first 8 characters of the task UUID: task/a1b2c3d4.
Multiple workspaces produce multiple worktrees, all grouped under ~/.wallfacer/worktrees/<task-uuid>/:
~/.wallfacer/worktrees/
└── <task-uuid>/
├── myapp/ # worktree for ~/projects/myapp
└── mylib/ # worktree for ~/projects/mylib
When a workspace is not a git repository (or is an empty git repo with no commits), setupNonGitSnapshot() (internal/runner/snapshot.go) creates a snapshot copy instead:
cp -a ws/. snapshotPathcopies all files including hidden ones.git init+git add -A+git commit --allow-emptyinitializes a local git repo for change tracking.- The standard commit pipeline (Phase 1) can then commit changes within the snapshot.
- Before extraction,
computeSnapshotDiff()captures a unified diff of all changes relative to the initial snapshot commit. This diff is stored inTask.SnapshotDiffsso the diff view works even after the snapshot is cleaned up. extractSnapshotToWorkspace()copies changes back usingrsync --delete --exclude=.git(falling back tocpif rsync is unavailable).
CreateWorktree() (internal/gitutil/worktree.go) handles the case where the branch already exists but the worktree directory was lost (e.g. after a server crash). If git worktree add -b fails because the branch or worktree entry already exists, it retries with git worktree add --force <path> <branch> to reattach the existing branch. CreateWorktreeAt() follows the same pattern but accepts an explicit base commit.
When ensureTaskWorktrees() finds a directory that exists but is not a valid git repo (e.g. the .git link was deleted or corrupted by a container), it removes the directory entirely and recreates the worktree from scratch.
The sandbox container sees worktrees, not the live main working directory:
~/.wallfacer/worktrees/<uuid>/<repo> -> /workspace/<repo>:z (read-write)
AGENTS.md (or CLAUDE.md) -> /workspace/AGENTS.md (read-only)
claude-config (named volume) -> /home/claude/.claude
The .env file is passed via --env-file, not as a bind mount.
The agent operates on /workspace/<repo> -- the isolated worktree branch -- so all edits land on task/<uuid8> and never touch main.
Git worktrees use a .git file (not a directory) that references the main repo's .git/worktrees/<name>/ via an absolute host path. To make git operations work inside the container, buildContainerArgsForSandbox() (internal/runner/container.go) bind-mounts the main repo's .git directory at the same absolute host path inside the container:
~/projects/myapp/.git -> ~/projects/myapp/.git:z (read-write)
When there is exactly one workspace, the container's working directory is set to /workspace/<basename> so the agent starts directly in the repo. For multiple workspaces, CWD is /workspace so all repos are accessible. See workdirForBasenames() in container.go.
Triggered automatically after end_turn, or manually when a user marks a waiting task as done. Runs three sequential phases in runner.go.
Staging and committing happen on the host. A container is launched only to generate the commit message, which is then used by the host-side git commit.
flowchart TD
Rebase["git rebase default-branch"] --> Check{"Conflicts?"}
Check -->|no| Merge["git merge --ff-only task-branch<br/>into default branch"]
Check -->|yes| Resolve["Invoke agent with conflict details<br/>(same session ID)"]
Resolve --> Continue["git rebase --continue"]
Continue --> Retry{"Still<br/>failing?"}
Retry -->|"no"| Merge
Retry -->|"yes (< 3 attempts)"| Resolve
Retry -->|"yes (exhausted)"| Failed["Task marked failed"]
Merge --> Hashes["Collect resulting commit hashes"]
DefaultBranch() (internal/gitutil/repo.go) resolves the target branch by checking, in order:
- Current local HEAD branch (so tasks merge back to whatever branch the user is working on)
origin/HEAD(remote default)- Falls back to
"main"
Stale rebase recovery: Before starting a new rebase, recoverRebaseState() checks for leftover REBASE_HEAD, MERGE_HEAD, or CHERRY_PICK_HEAD refs. If found, it aborts the stale operation (git rebase --abort, git merge --abort) and clears conflicted paths via git reset --merge, falling back to git restore or git reset --hard HEAD.
Conflict resolution loop: If git rebase exits non-zero, Wallfacer invokes the agent again -- using the original task's session ID -- passing it the conflict details. The agent resolves the conflicts and stages the result. The rebase is then continued and retried. Up to 3 attempts are made before the task is marked failed.
Stash operations: StashIfDirty() and StashPop() (internal/gitutil/stash.go) are used during conflict resolution to preserve uncommitted changes. A failed StashPop aborts via git checkout -- . + git clean -fd to restore a clean state, preserving the stash entry for manual recovery.
git worktree remove --force <- remove worktree directory
git branch -D task/<uuid8> <- delete task branch
rm -rf ~/.wallfacer/worktrees/<uuid>/ <- remove task worktree directory
RemoveWorktree() (internal/gitutil/worktree.go) handles edge cases: if the directory is already gone, it runs git worktree prune and continues to the branch deletion. Branch deletion is best-effort and always attempted.
Note: data/<uuid>/ (task record, traces, outputs, oversights, summary) is preserved after cleanup so execution history remains accessible in the UI.
Cleanup is idempotent and safe to call multiple times (errors are logged, not fatal). Span events (worktree_cleanup) are recorded in the task's audit trail.
Workspace management has moved. See Workspaces & Configuration for workspace management.
AGENTS.md lifecycle has moved. See Workspaces & Configuration for AGENTS.md lifecycle.
Tasks can optionally see the working directories of other active tasks via read-only bind-mounts.
The MountWorktrees boolean field on the Task model controls whether sibling worktrees are mounted into this task's container. It is set at task creation time (via TaskCreateOptions.MountWorktrees) and can be toggled on backlog tasks via PATCH /api/tasks/{id}.
canMountWorktree() (internal/runner/board.go) determines which sibling tasks are eligible for mounting:
| Sibling status | Eligible? | Reason |
|---|---|---|
waiting |
Yes | Worktree exists, not actively being modified |
failed |
Yes | Worktree exists, not actively being modified |
done |
Only if worktree directory still exists on disk | Worktrees may have been cleaned up already |
in_progress |
No | Actively being modified by another agent |
backlog |
No | No worktree exists yet |
cancelled / archived |
No | Worktrees have been cleaned up |
Additionally, siblings must share at least one workspace with the requesting task (checked by sharesWorkspace()).
Sibling worktrees are mounted read-only under /workspace/.tasks/worktrees/<short-id>/<repo>/:
/workspace/.tasks/
board.json # board manifest (read-only)
worktrees/
abcd1234/ # sibling task short ID (first 8 chars of UUID)
myapp/ # worktree for ~/projects/myapp
mylib/ # worktree for ~/projects/mylib
ef567890/
myapp/
The mount options are z,ro (SELinux relabel + read-only).
generateBoardContextAndMounts() produces both the board.json content and the sibling mount map in a single ListTasks call. Each eligible sibling's BoardTask entry includes a worktree_mount field pointing to the container path, so the agent knows where to find the sibling's files. Results are cached by (boardChangeSeq, selfTaskID) to avoid redundant computation across turns.
DefaultBranch() (internal/gitutil/repo.go) prefers the currently checked-out branch so tasks merge back to whatever branch the user is working on (e.g. develop), not necessarily the remote's default:
git branch --show-current-- returns the local HEAD branchgit symbolic-ref --short refs/remotes/origin/HEAD-- remote default (stripsorigin/prefix)- Falls back to
"main"
RemoteDefaultBranch() is a separate function that only considers the remote, used for the git status UI. It checks origin/HEAD, then probes origin/main and origin/master.
Both GitCheckout() and GitCreateBranch() (internal/handler/git.go) call refuseWorkspaceMutationIfBlocked() before modifying the workspace. This function checks for tasks in in_progress, waiting, committing, or failed status that have worktree paths pointing to the target workspace.
A task blocks workspace mutation if:
- It has a
WorktreePathsentry for the workspace, AND - Its status is not
failed, OR its worktree directory still exists on disk
When blocking tasks exist, the handler returns 409 Conflict with the list of blocking task IDs, titles, and statuses, so the UI can display which tasks must be completed or cancelled first.
The same guard is applied to git sync, git push, and git rebase-on-main operations.
POST /api/git/create-branch validates the branch name (no .., spaces, or control characters), checks the workspace mutation guard, then runs git checkout -b <branch> on the workspace. All future task worktrees branch from the new HEAD.
POST /api/git/checkout follows the same validation and guard pattern, then runs git checkout <branch>. The UI header displays a branch switcher dropdown per workspace.
PruneUnknownWorktrees() (internal/runner/worktree.go) runs synchronously on every server startup (called from server.go):
- Scan
~/.wallfacer/worktrees/for subdirectories - Load all task IDs from the store (including archived)
- For each directory whose name is not a known task UUID: remove the directory
- Run
git worktree pruneon all git workspaces to clear stale internal refs from.git/worktrees/
This handles crashes or ungraceful shutdowns where cleanup never ran.
StartWorktreeGC(ctx) (internal/runner/worktree_gc.go) runs as a background goroutine, started from server.go. It periodically scans for and removes orphaned worktrees that belong to terminal tasks.
Inspects ~/.wallfacer/worktrees/ and returns task UUIDs whose:
- Task does not exist in the store, OR
- Task is in a terminal state (
done,cancelled) or isarchived
Tasks in backlog, in_progress, waiting, committing, or failed are preserved.
For each orphaned task ID:
- Reads subdirectories under
~/.wallfacer/worktrees/<task-uuid>/ - Matches subdirectory names to workspace basenames
- Runs
git worktree remove --forcefor each matched worktree (best-effort) - Removes the entire task directory via
os.RemoveAll - After all orphans are processed, runs
git worktree pruneon all workspaces
Holds worktreeMu for the entire operation to prevent concurrent worktree mutations.
- Default interval: 24 hours (configurable via
WALLFACER_WORKTREE_GC_INTERVAL, e.g."6h","30m") - Does not run an initial scan at startup (that is handled by
PruneUnknownWorktrees())
StartWorktreeHealthWatcher(ctx) (internal/runner/worktree_gc.go) runs as a separate background goroutine, distinct from the GC. Its concern is the opposite: instead of removing orphans, it restores missing worktrees for live tasks.
Iterates over tasks in in_progress, waiting, and committing states. For each task, checks every WorktreePaths entry:
- If the path does not exist on disk: flagged as missing
- If the path exists but is not a valid git repo (
.gitlink broken): directory is removed and task is flagged for restoration
For each flagged task (skipping those with an empty BranchName):
- Calls
ensureTaskWorktrees()to recreate the worktree from the existing branch - Inserts a
systemevent: "worktree restored by health watcher" - Increments the
wallfacer_worktree_restorations_totalPrometheus counter
- Runs an initial scan immediately at startup (before the first tick)
- Then repeats every 2 minutes (
defaultWorktreeHealthInterval)
flowchart LR
subgraph Startup["Server Start"]
Prune["PruneUnknownWorktrees()<br/>(synchronous)"]
end
subgraph Background["Background Goroutines"]
GC["StartWorktreeGC<br/>every 24h: remove orphans"]
Health["StartWorktreeHealthWatcher<br/>every 2min: restore missing"]
end
Prune --> GC
Prune --> Health
Tasks in waiting or failed status can be synced with the latest default branch via POST /api/tasks/{id}/sync. This rebases the task worktree onto the current default branch HEAD without merging, keeping the task's changes on top.
flowchart TD
Sync["POST /api/tasks/{id}/sync"] --> InProgress["Task status<br/>to in_progress (temporarily)"]
InProgress --> Fetch["For each worktree:<br/>git fetch origin"]
Fetch --> Rebase["git rebase default-branch"]
Rebase --> Result{"Rebase<br/>succeeded?"}
Result -->|yes| Restore["Restore previous status<br/>(waiting or failed)"]
Result -->|no| Resolve["Invoke agent<br/>(same session)<br/>to resolve conflicts"]
Resolve --> Retry{"Resolved<br/>within 3 attempts?"}
Retry -->|yes| Restore
Retry -->|no| RunAgent["Invoke agent (Run)<br/>with conflict prompt<br/>task stays in_progress"]
RunAgent --> AgentResult["Agent resolves conflict<br/>task to waiting or done"]
This is useful when other tasks have merged changes to the default branch and you want the current task to pick them up before continuing.
GET /api/tasks/{id}/diff returns the diff of a task's changes against the default branch. It handles multiple scenarios:
- Active worktrees -- uses
merge-baseto diff only the task's changes since it diverged, including untracked files (viagit diff --no-index /dev/null <file>) - Merged tasks (worktree cleaned up) -- falls back to stored
CommitHashes/BaseCommitHashesor branch names to reconstruct the diff - Returns
behind_countsper repo indicating how many commits the default branch has advanced since the task branched off - Non-git workspaces -- for active tasks, the diff is computed live from the snapshot's git repo; for terminal tasks, the stored
SnapshotDiffscaptured at commit time are returned - Caching -- terminal tasks (done/cancelled/archived) are cached with
immutableCache-Control; active tasks are cached for 10 seconds with ETag support for conditional requests
Git operations are organized in the internal/gitutil package:
| File | Purpose |
|---|---|
repo.go |
Repository queries: IsGitRepo, HasCommits, DefaultBranch, RemoteDefaultBranch, GetCommitHash, GetCommitHashForRef |
worktree.go |
Worktree lifecycle: CreateWorktree, CreateWorktreeAt, RemoveWorktree, ResolveHead |
ops.go |
Git operations: RebaseOntoDefault, FFMerge, HasCommitsAheadOf, CommitsBehind, MergeBase, BranchTipCommit, FetchOrigin, IsConflictOutput, HasConflicts |
stash.go |
Stash operations: StashIfDirty, StashPop |
status.go |
Workspace git status: WorkspaceStatus, WorkspaceGitStatus struct |
repo.go includes several conflict detection functions:
IsConflictOutput(s)-- checks forCONFLICT,Merge conflict,conflictsubstringsIsRebaseNeedsMergeOutput(s)-- detects blocked rebase states ("needs merge", "rebase in progress", "cannot rebase" with dirty index, etc.)HasConflicts(worktreePath)-- parsesgit status --porcelainfor unmerged status codes (UU,AA,DD,AU,UA,DU,UD)parseConflictedFiles(output)-- extracts file paths fromCONFLICT (...)lines via regex
The server exposes git status and branch management for the UI header bar. See API & Transport for the full API route list.
GET /api/git/status-- current branch, remote tracking, ahead/behind counts per workspaceGET /api/git/stream-- SSE endpoint pushing git status updates (5-second poll interval)POST /api/git/push-- rungit pushon a workspacePOST /api/git/sync-- fetch from remote and rebase workspace onto upstreamGET /api/git/branches?workspace=<path>-- list all local branches for a workspace; returns{branches: [...], current: "main"}POST /api/git/checkout-- switch the active branch ({workspace, branch}); refuses while tasks are in progressPOST /api/git/create-branch-- create and checkout a new branch ({workspace, branch}); refuses while tasks are in progressPOST /api/git/rebase-on-main-- fetch origin/main and rebase the current branch on topPOST /api/git/open-folder-- open a workspace directory in the OS file manager
All git mutation endpoints (push, sync, rebase-on-main, checkout, create-branch) return a 400 error for non-git workspaces and a 409 error when blocking tasks exist.
The UI header displays a branch switcher dropdown for each workspace. Users can:
- Switch branches -- select an existing branch from the dropdown. The server runs
git checkouton the workspace. All future task worktrees branch from the new HEAD. - Create branches -- type a new branch name in the search field and select "Create branch". The server runs
git checkout -bon the workspace.
Both operations are blocked while any task is in_progress, waiting, committing, or failed (with existing worktrees) to prevent worktree conflicts.
- Workspaces & Configuration -- workspace manager, workspace key hashing, hot-swap, AGENTS.md lifecycle
- API & Transport -- HTTP routes, SSE, metrics, middleware
- Task Lifecycle -- task state machine and execution loop