Completed phases showed "No phases pending review" because: 1. Frontend filtered only pending_review phases 2. Server rejected non-pending_review phases 3. After merge, three-dot diff returned empty (merge base moved) Fix: store pre-merge merge base hash on phase, use it to reconstruct diffs for completed phases. Frontend now shows both pending_review and completed phases with read-only mode (Merged badge) for completed ones.
6.0 KiB
Git, Process, and Logging Modules
Three infrastructure modules supporting agent execution.
Git Module (apps/server/git/)
Manages git worktrees for isolated agent workspaces.
Architecture
- Port:
WorktreeManagerinterface - Adapter:
SimpleGitWorktreeManagerusing simple-git library
WorktreeManager Methods
| Method | Purpose |
|---|---|
create(id, branch, baseBranch?) |
Create worktree with new branch (default base: 'main') |
remove(id) |
Clean up worktree directory |
list() |
All worktrees including main |
get(id) |
Specific worktree by ID |
diff(id) |
Changed files vs HEAD |
merge(id, targetBranch) |
Merge worktree branch into target |
Worktree Storage
Worktrees stored in .cw-worktrees/ subdirectory of the repo. Each agent gets a worktree at .cw-worktrees/agent/<alias>/.
Merge Flow
- Check out target branch
git merge <source> --no-edit- On success: emit
worktree:merged - On conflict:
git merge --abort, emitworktree:conflictwith conflicting file list - Restore original branch
BranchManager (apps/server/git/branch-manager.ts)
- Port:
BranchManagerinterface - Adapter:
SimpleGitBranchManagerusing simple-git
| Method | Purpose |
|---|---|
ensureBranch(repoPath, branch, baseBranch) |
Create branch from base if it doesn't exist (idempotent) |
mergeBranch(repoPath, source, target) |
Merge via ephemeral worktree, returns conflict info |
diffBranches(repoPath, base, head) |
Three-dot diff between branches |
deleteBranch(repoPath, branch) |
Delete local branch (no-op if missing) |
branchExists(repoPath, branch) |
Check local branches |
remoteBranchExists(repoPath, branch) |
Check remote tracking branches (origin/<branch>) |
listCommits(repoPath, base, head) |
List commits head has that base doesn't (with stats) |
diffCommit(repoPath, commitHash) |
Get unified diff for a single commit |
getMergeBase(repoPath, branch1, branch2) |
Get common ancestor commit hash |
remoteBranchExists is used by registerProject and updateProject to validate that a project's default branch actually exists in the cloned repository before saving.
Project Clones
cloneProject(url, destPath)— Simple git clone wrapperensureProjectClone(project, workspaceRoot)— Idempotent: checks if clone exists, clones if notgetProjectCloneDir(name, id)— Canonical path:repos/<sanitized-name>-<id>/
ProjectSyncManager (apps/server/git/remote-sync.ts)
Fetches remote changes for project clones and fast-forwards the local defaultBranch. Safe to run with active worktrees — only updates remote-tracking refs and the base branch (never checked out directly by any worktree).
Constructor: ProjectSyncManager(projectRepository, workspaceRoot, eventBus?)
| Method | Purpose |
|---|---|
syncProject(projectId) |
git fetch origin + git merge --ff-only origin/<defaultBranch>, updates lastFetchedAt |
syncAllProjects() |
Sync all registered projects sequentially |
getSyncStatus(projectId) |
Returns { ahead, behind, lastFetchedAt } via rev-list --left-right --count |
getInitiativeDivergence(projectId, branch) |
Ahead/behind between defaultBranch and an initiative branch |
Sync flow during phase dispatch: dispatchNextPhase() syncs all linked project clones before creating initiative/phase branches, so branches fork from up-to-date remote state. Sync is best-effort — failures are logged but don't block dispatch.
CLI: cw project sync [name] --all, cw project status [name]
tRPC: syncProject, syncAllProjects, getProjectSyncStatus
Events Emitted
worktree:created, worktree:removed, worktree:merged, worktree:conflict, project:synced, project:sync_failed
Process Module (apps/server/process/)
Spawns, tracks, and controls child processes.
Classes
ProcessRegistry — In-memory metadata store:
register(info),unregister(id),get(id),getAll(),getByPid(pid),updateStatus(id, status)
ProcessManager — Lifecycle management:
| Method | Purpose |
|---|---|
spawn(options) |
Spawn detached process (survives parent exit) |
stop(id) |
SIGTERM → wait 5s → SIGKILL |
stopAll() |
Stop all running processes in parallel |
restart(id) |
Stop + re-spawn with same options |
isRunning(id) |
Check with process.kill(pid, 0) |
Spawn Details
- Uses
execawithdetached: true,stdio: 'ignore' - Calls
subprocess.unref()so parent can exit - Exit handler updates registry and emits events
Events Emitted
process:spawned, process:stopped, process:crashed
Logger Module (apps/server/logger/)
Structured logging via pino.
Usage
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('my-module');
log.info({ key: 'value' }, 'message');
Configuration
| Env Var | Effect |
|---|---|
CW_LOG_LEVEL |
Override log level |
CW_LOG_PRETTY |
Set to '1' for human-readable output |
NODE_ENV=development |
Default to 'debug' level |
Output
- Default: JSON to stderr (fd 2)
- Pretty mode: pino-pretty to stdout with colors and timestamps
Logging Module (apps/server/logging/)
File-based per-process output capture (separate from pino).
Classes
LogManager — Directory management:
- Base dir:
~/.cw/logs/ - Structure:
{processId}/stdout.log,{processId}/stderr.log cleanOldLogs(retainDays)— removes old directories by mtime
ProcessLogWriter — File I/O with timestamps:
open()— create directories and append-mode WriteStreamswriteStdout(data)/writeStderr(data)— prefix each line with[YYYY-MM-DD HH:mm:ss.SSS]- Handles backpressure (waits for drain event)
- Emits
log:entryevent via EventBus
Factory
import { createLogger } from './logging/index.js';
const writer = createLogger(processId, eventBus);
await writer.open();
await writer.writeStdout('output data');
await writer.close();