diff --git a/.kiro/specs/loop-engine/tasks.md b/.kiro/specs/loop-engine/tasks.md index 5e5e582..5362497 100644 --- a/.kiro/specs/loop-engine/tasks.md +++ b/.kiro/specs/loop-engine/tasks.md @@ -50,9 +50,9 @@ Each task references the requirement(s) it satisfies. ## Milestone 4 — Parallel execution -- [ ] 19. Dependency-graph scheduler honoring the parallelism limit _(Req 9)_ -- [ ] 20. Isolated workspaces (git worktrees) per concurrent task _(Req 9)_ -- [ ] 21. Integrator: merge completed work, run full verification, surface +- [~] 19. Dependency-graph scheduler honoring the parallelism limit _(Req 9)_ +- [~] 20. Isolated workspaces (git worktrees) per concurrent task _(Req 9)_ +- [~] 21. Integrator: merge completed work, run full verification, surface conflicts _(Req 9)_ ## Milestone 5 — Observability diff --git a/src/engine/integrator.ts b/src/engine/integrator.ts new file mode 100644 index 0000000..f64d686 --- /dev/null +++ b/src/engine/integrator.ts @@ -0,0 +1,115 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { git, GitError, spawnGit, type GitExec } from "../workspace/git.js"; +import { runMechanicalGate, type CommandExecutor } from "./mechanicalGate.js"; +import type { MechanicalGateResult } from "../schemas/artifact.js"; + +/** + * Integrator (Task 21): merges the branches produced by parallel tasks back + * together and runs full verification on the combined result (Req 9.3, 9.4). + * + * Integration happens in a dedicated worktree on a fresh integration branch, so + * the source repo's checked-out state is never disturbed. Branches are merged + * in the given (dependency) order; a conflicting merge is **aborted and + * surfaced** with its conflicting paths rather than force-resolved, so a human + * can decide. After the clean merges, the configured verification commands run + * against the integrated tree. + */ + +export interface IntegrationBranch { + taskId: string; + branch: string; +} + +export interface IntegrationConflict { + taskId: string; + branch: string; + /** unmerged paths reported by git for this merge */ + files: string[]; +} + +export interface IntegrationResult { + /** task ids merged cleanly, in order */ + merged: string[]; + /** branches that could not be merged without conflict (surfaced, not merged) */ + conflicts: IntegrationConflict[]; + /** the integration branch holding the merged result */ + integrationBranch: string; + /** verification run on the integrated tree (absent when no commands given) */ + verification?: MechanicalGateResult; + /** true when every branch merged cleanly AND verification passed */ + ok: boolean; +} + +export interface IntegrateInput { + repoDir: string; + branches: IntegrationBranch[]; + /** ref to integrate on top of (default: HEAD) */ + baseRef?: string; + /** name for the integration branch (default: unique per call) */ + integrationBranch?: string; + /** commands run on the integrated result (full verification) */ + verifyCommands?: string[]; + /** executor for the verification gate (defaults to the gate's real one) */ + executor?: CommandExecutor; + git?: GitExec; + log?: (line: string) => void; +} + +/** Lists unmerged (conflicted) paths in a worktree mid-merge. */ +async function conflictedFiles(exec: GitExec, cwd: string): Promise { + const res = await exec(["diff", "--name-only", "--diff-filter=U"], cwd); + return res.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); +} + +export async function integrate(input: IntegrateInput): Promise { + const exec = input.git ?? spawnGit; + const baseRef = input.baseRef ?? "HEAD"; + const integrationBranch = input.integrationBranch ?? `loopwright/integration/${randomUUID().slice(0, 8)}`; + const dir = path.join(input.repoDir, ".loopwright", "integration", randomUUID().slice(0, 8)); + + // Isolate integration in its own worktree so the repo checkout is untouched. + await git(exec, ["worktree", "add", "-f", "-B", integrationBranch, dir, baseRef], input.repoDir); + + const merged: string[] = []; + const conflicts: IntegrationConflict[] = []; + + try { + for (const { taskId, branch } of input.branches) { + try { + await git(exec, ["merge", "--no-ff", "-m", `integrate ${taskId}`, branch], dir); + merged.push(taskId); + input.log?.(`integrated ${taskId} (${branch})`); + } catch (err) { + if (err instanceof GitError) { + const files = await conflictedFiles(exec, dir); + // back out the failed merge so subsequent merges can proceed + await exec(["merge", "--abort"], dir); + conflicts.push({ taskId, branch, files }); + input.log?.(`CONFLICT integrating ${taskId} (${branch}): ${files.join(", ") || "unknown"}`); + } else { + throw err; + } + } + } + + let verification: MechanicalGateResult | undefined; + if (input.verifyCommands && input.verifyCommands.length > 0) { + verification = await runMechanicalGate(input.verifyCommands, { + cwd: dir, + ...(input.executor ? { executor: input.executor } : {}), + }); + } + + return { + merged, + conflicts, + integrationBranch, + ...(verification ? { verification } : {}), + ok: conflicts.length === 0 && (verification?.passed ?? true), + }; + } finally { + // Remove the integration worktree; the integration branch is kept for review. + await exec(["worktree", "remove", "--force", dir], input.repoDir); + } +} diff --git a/src/engine/scheduler.ts b/src/engine/scheduler.ts new file mode 100644 index 0000000..ddd3b37 --- /dev/null +++ b/src/engine/scheduler.ts @@ -0,0 +1,204 @@ +import type { TaskSpec } from "../schemas/plan.js"; +import type { TaskState } from "../domain/stateMachine.js"; +import { runTask, type LoopDeps, type TaskOutcome } from "./loop.js"; + +/** + * Dependency-graph scheduler (Task 19). + * + * Runs a plan's tasks as a DAG: a task starts only once every dependency has + * reached an *unblocking* terminal state, at most `config.maxParallel` build + * concurrently, and a dependent is SKIPPED (transitively) when a prerequisite + * does not reach a usable state — so nothing is built on a broken base. + * + * Responsibilities the per-task loop (runTask) deliberately doesn't own: + * ordering, concurrency, and dependency-failure propagation. The graph is + * validated up front (duplicate ids, missing/self refs, cycles) so a malformed + * plan fails fast instead of deadlocking. + * + * Two seams keep later milestones additive: + * - `workspaceFor` resolves a per-task working directory (git worktrees, + * Task 20) — default is the shared cwd. + * - `resumeOutcome` lets a completed task from a prior run be reused without + * rebuilding (checkpoint/resume, Milestone 3). + */ + +/** + * Terminal states that allow dependents to proceed. UNVERIFIED_BY_CRITIC is + * included because it's an intentional proceed-degraded outcome; NEEDS_HUMAN is + * not — building dependents on it would compound the failure. + */ +const UNBLOCKING_STATES: ReadonlySet = new Set([ + "GREEN", + "UNVERIFIED_BY_CRITIC", +]); + +export interface SchedulerDeps extends LoopDeps { + /** resolves the working directory a task builds in; defaults to deps.cwd */ + workspaceFor?: (task: TaskSpec) => string | Promise; + /** reuse a prior completed outcome instead of rebuilding (resume) */ + resumeOutcome?: (task: TaskSpec) => Promise | TaskOutcome | undefined; + /** called after each task settles (for cleanup, e.g. worktree teardown) */ + onTaskSettled?: (task: TaskSpec, result: ScheduledResult) => void | Promise; +} + +export type ScheduledTaskStatus = "completed" | "skipped" | "resumed"; + +export interface ScheduledResult { + taskId: string; + status: ScheduledTaskStatus; + /** present for "completed"/"resumed" */ + outcome?: TaskOutcome; + /** dependency ids that prevented this task (status === "skipped") */ + blockedBy?: string[]; +} + +export class InvalidPlanGraphError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidPlanGraphError"; + } +} + +/** Validates references and acyclicity; throws on the first problem found. */ +export function validateGraph(tasks: TaskSpec[]): void { + const ids = new Set(); + for (const t of tasks) { + if (ids.has(t.id)) throw new InvalidPlanGraphError(`Duplicate task id: "${t.id}".`); + ids.add(t.id); + } + + const byId = new Map(tasks.map((t) => [t.id, t])); + for (const t of tasks) { + for (const dep of t.dependencies) { + if (dep === t.id) throw new InvalidPlanGraphError(`Task "${t.id}" depends on itself.`); + if (!byId.has(dep)) { + throw new InvalidPlanGraphError(`Task "${t.id}" depends on unknown task "${dep}".`); + } + } + } + + // Cycle detection via DFS coloring (WHITE=unseen, GRAY=on stack, BLACK=done). + const color = new Map(tasks.map((t) => [t.id, 0])); + const visit = (id: string, path: string[]): void => { + color.set(id, 1); + for (const dep of (byId.get(id) as TaskSpec).dependencies) { + const c = color.get(dep); + if (c === 1) { + throw new InvalidPlanGraphError(`Dependency cycle detected: ${[...path, dep].join(" -> ")}.`); + } + if (c === 0) visit(dep, [...path, dep]); + } + color.set(id, 2); + }; + for (const t of tasks) { + if (color.get(t.id) === 0) visit(t.id, [t.id]); + } +} + +function isUnblocking(result: ScheduledResult | undefined): boolean { + return ( + result !== undefined && + result.status !== "skipped" && + result.outcome !== undefined && + UNBLOCKING_STATES.has(result.outcome.finalState) + ); +} + +/** A finished dependency blocks dependents if it was skipped or ended non-unblocking. */ +function isBlocking(result: ScheduledResult | undefined): boolean { + if (result === undefined) return false; // not finished yet + return !isUnblocking(result); +} + +/** + * Executes all tasks honoring dependencies + the parallelism cap. Returns one + * result per task in the input's declared order. + */ +export async function runScheduledTasks( + tasks: TaskSpec[], + deps: SchedulerDeps, +): Promise { + validateGraph(tasks); + + const byId = new Map(tasks.map((t) => [t.id, t])); + const workspaceFor = deps.workspaceFor ?? (() => deps.cwd); + const maxParallel = Math.max(1, deps.config.maxParallel); + + const results = new Map(); + const remaining = new Set(tasks.map((t) => t.id)); + + // Resume pre-pass: reuse any task already completed in a prior run. Reused + // tasks count as unblocking for their dependents. + if (deps.resumeOutcome) { + for (const t of tasks) { + const prior = await deps.resumeOutcome(t); + if (prior && UNBLOCKING_STATES.has(prior.finalState)) { + const r: ScheduledResult = { taskId: t.id, status: "resumed", outcome: prior }; + results.set(t.id, r); + remaining.delete(t.id); + deps.log?.(`[${t.id}] RESUMED -- ${prior.finalState} (skipped rebuild)`); + await deps.onTaskSettled?.(t, r); + } + } + } + + const running = new Map>(); + + const depsSatisfied = (t: TaskSpec): boolean => + t.dependencies.every((d) => isUnblocking(results.get(d))); + const blockingDeps = (t: TaskSpec): string[] => + t.dependencies.filter((d) => isBlocking(results.get(d))); + + while (remaining.size > 0 || running.size > 0) { + // 1) Cascade-skip remaining tasks whose dependency is already blocked. + let changed = true; + while (changed) { + changed = false; + for (const id of [...remaining]) { + const bad = blockingDeps(byId.get(id) as TaskSpec); + if (bad.length > 0) { + const r: ScheduledResult = { taskId: id, status: "skipped", blockedBy: bad }; + results.set(id, r); + remaining.delete(id); + deps.log?.(`[${id}] SKIPPED -- blocked by ${bad.join(", ")}`); + await deps.onTaskSettled?.(byId.get(id) as TaskSpec, r); + changed = true; + } + } + } + + // 2) Launch ready tasks up to the parallelism cap. + for (const id of [...remaining]) { + if (running.size >= maxParallel) break; + const task = byId.get(id) as TaskSpec; + if (!depsSatisfied(task)) continue; + remaining.delete(id); + const cwd = await workspaceFor(task); + deps.log?.(`[${id}] START`); + running.set( + id, + runTask(task, { ...deps, cwd }).then((outcome) => ({ id, outcome })), + ); + } + + // 3) Nothing running and nothing launchable: all work is resolved. + if (running.size === 0) break; + + // 4) Wait for the next task to finish, record it, and re-evaluate. + const { id, outcome } = await Promise.race(running.values()); + running.delete(id); + const r: ScheduledResult = { taskId: id, status: "completed", outcome }; + results.set(id, r); + deps.log?.(`[${id}] DONE -- ${outcome.finalState}`); + await deps.onTaskSettled?.(byId.get(id) as TaskSpec, r); + } + + return tasks.map( + (t) => + results.get(t.id) ?? { + taskId: t.id, + status: "skipped" as const, + blockedBy: [], + }, + ); +} diff --git a/src/session.ts b/src/session.ts index 07a2c06..5746635 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,16 +1,17 @@ import { randomUUID } from "node:crypto"; import type { LoopwrightConfig } from "./config.js"; -import type { TaskSpec } from "./schemas/plan.js"; -import type { TaskState } from "./domain/stateMachine.js"; import type { Finding } from "./schemas/critic.js"; import { createRoles, type CreateRolesOptions } from "./adapters/roleBindings.js"; import { runPlanReview, - runTask, type LoopObserver, type PlanOutcome, type TaskOutcome, } from "./engine/loop.js"; +import { runScheduledTasks, type SchedulerDeps } from "./engine/scheduler.js"; +import { integrate, type IntegrationResult } from "./engine/integrator.js"; +import { GitWorktreeManager } from "./workspace/worktrees.js"; +import type { IntegrationBranch } from "./engine/integrator.js"; import type { CommandExecutor } from "./engine/mechanicalGate.js"; import type { Store } from "./storage/store.js"; import { storeObserver, combineObservers } from "./storage/checkpoint.js"; @@ -18,13 +19,13 @@ import { storeObserver, combineObservers } from "./storage/checkpoint.js"; /** * End-to-end run (Task 15): goal -> reviewed plan -> per-task actor-critic loop * -> session summary. This is the headless entrypoint a CLI or desktop shell - * calls; it ties configuration, the role-binding layer, and the Milestone 1 - * loop together without adding any new orchestration policy. + * calls; it ties configuration, the role-binding layer, persistence, and the + * loop together without adding new orchestration policy. * - * Tasks execute sequentially in dependency order here; a dependent is SKIPPED - * when a prerequisite did not reach a usable terminal state. Bounded concurrency - * and isolated worktrees arrive in Milestone 4 and will slot in behind this same - * entrypoint. + * Task execution is delegated to the dependency-graph scheduler (Task 19), so + * independent tasks run concurrently up to `config.maxParallel`, dependents + * wait for (and are skipped on) unsatisfied prerequisites, and completed tasks + * are reused on resume. */ export type SessionTaskStatus = "completed" | "skipped" | "resumed"; @@ -51,6 +52,8 @@ export interface SessionResult { skipped: string[]; /** true only when every task reached a verified GREEN */ allVerified: boolean; + /** present when worktrees were used: merge + full-verification result */ + integration?: IntegrationResult; } export interface RunGoalOptions extends CreateRolesOptions { @@ -64,43 +67,16 @@ export interface RunGoalOptions extends CreateRolesOptions { resume?: boolean; /** extra observer composed with the store checkpointer (e.g. event log) */ observer?: LoopObserver; -} - -/** Terminal task states that allow dependents to proceed (see scheduler, M4). */ -function isUnblockingState(state: TaskState): boolean { - return state === "GREEN" || state === "UNVERIFIED_BY_CRITIC"; -} - -function isUnblocking(outcome: TaskOutcome | undefined): boolean { - return outcome !== undefined && isUnblockingState(outcome.finalState); -} - -/** Orders tasks so dependencies precede dependents (stable; rejects cycles). */ -function dependencyOrder(tasks: TaskSpec[]): TaskSpec[] { - const byId = new Map(tasks.map((t) => [t.id, t])); - const visited = new Set(); - const ordered: TaskSpec[] = []; - const visit = (task: TaskSpec, stack: string[]): void => { - if (visited.has(task.id)) return; - if (stack.includes(task.id)) { - // A back-edge means the plan's dependency graph is cyclic and cannot be - // scheduled. Fail loudly with the offending cycle rather than silently - // dropping the edge (which would yield a partial order and cascading - // skips); plans are expected to be acyclic (enforced in critic review). - const cycle = [...stack.slice(stack.indexOf(task.id)), task.id].join(" -> "); - throw new Error(`Plan dependency cycle detected: ${cycle}`); - } - stack.push(task.id); - for (const depId of task.dependencies) { - const dep = byId.get(depId); - if (dep) visit(dep, stack); - } - stack.pop(); - visited.add(task.id); - ordered.push(task); - }; - for (const t of tasks) visit(t, []); - return ordered; + /** per-task working directory provider (git worktrees, Task 20) */ + workspaceFor?: SchedulerDeps["workspaceFor"]; + /** called after each task settles (e.g. worktree teardown, Task 20) */ + onTaskSettled?: SchedulerDeps["onTaskSettled"]; + /** + * When set (and config.useWorktrees), each task builds in an isolated git + * worktree off this repo and the resulting branches are integrated + verified + * after the run (Tasks 20, 21). + */ + repoDir?: string; } export async function runGoal( @@ -108,8 +84,17 @@ export async function runGoal( config: LoopwrightConfig, opts: RunGoalOptions = {}, ): Promise { - const { executor, store, sessionId: sessionIdOpt, resume, observer: extraObserver, ...roleOpts } = - opts; + const { + executor, + store, + sessionId: sessionIdOpt, + resume, + observer: extraObserver, + workspaceFor, + onTaskSettled, + repoDir, + ...roleOpts + } = opts; const { actor, critic } = createRoles(config, roleOpts); const cwd = opts.cwd ?? "."; @@ -153,44 +138,50 @@ export async function runGoal( }); } - const outcomes = new Map(); - const results: SessionTaskResult[] = []; - - for (const task of dependencyOrder(plan.plan.tasks)) { - // Resume: a task already completed (GREEN/unverified) in a prior run is - // reused as-is, so an interrupted run doesn't repeat finished work. - if (resume && store && sessionId) { - const prior = await store.getOutcome(sessionId, task.id); - if (prior && isUnblockingState(prior.finalState)) { - outcomes.set(task.id, prior.outcome); - results.push({ taskId: task.id, status: "resumed", outcome: prior.outcome }); - opts.log?.(`[${task.id}] RESUMED -- ${prior.finalState} (skipped rebuild)`); - continue; + // Isolated worktrees + integration (Tasks 20, 21), opt-in via repoDir. Each + // task builds in its own git worktree; on success its changes are committed + // on a per-task branch for the integrator to merge after the run. + const useWorktrees = Boolean(repoDir) && config.useWorktrees; + const wtManager = useWorktrees + ? new GitWorktreeManager({ repoDir: repoDir as string, sessionId: sessionId ?? randomUUID() }) + : undefined; + const greenBranches: IntegrationBranch[] = []; + + const schedulerExtra: Partial = {}; + if (wtManager) { + schedulerExtra.workspaceFor = async (t) => (await wtManager.acquire(t.id)).path; + schedulerExtra.onTaskSettled = async (t, r) => { + const unblocking = + r.outcome?.finalState === "GREEN" || r.outcome?.finalState === "UNVERIFIED_BY_CRITIC"; + if (r.status === "completed" && unblocking) { + const { committed } = await wtManager.commit(t.id, `loopwright(${t.id}): ${t.title}`); + const branch = wtManager.branchFor(t.id); + if (committed && branch) greenBranches.push({ taskId: t.id, branch }); + } else if (r.status !== "resumed") { + await wtManager.release(t.id, { deleteBranch: true }); } - } - - const blockedBy = task.dependencies.filter((d) => !isUnblocking(outcomes.get(d))); - if (blockedBy.length > 0) { - results.push({ taskId: task.id, status: "skipped", blockedBy }); - opts.log?.(`[${task.id}] SKIPPED -- blocked by ${blockedBy.join(", ")}`); - continue; - } - const outcome = await runTask(task, baseDeps); - outcomes.set(task.id, outcome); - results.push({ taskId: task.id, status: "completed", outcome }); + }; + } else { + if (workspaceFor) schedulerExtra.workspaceFor = workspaceFor; + if (onTaskSettled) schedulerExtra.onTaskSettled = onTaskSettled; } - // Summaries in the plan's declared order for stable, readable output. - const ordered = plan.plan.tasks.map( - (t) => results.find((r) => r.taskId === t.id) as SessionTaskResult, - ); + // The scheduler owns ordering, the parallelism cap, dependency-failure + // propagation, and (via resumeOutcome) reuse of completed tasks. + const results = await runScheduledTasks(plan.plan.tasks, { + ...baseDeps, + ...schedulerExtra, + ...(resume && store && sessionId + ? { resumeOutcome: async (t) => (await store.getOutcome(sessionId, t.id))?.outcome } + : {}), + }); const green: string[] = []; const unverified: string[] = []; const needsHuman: string[] = []; const skipped: string[] = []; - for (const r of ordered) { + for (const r of results) { if (r.status === "skipped") skipped.push(r.taskId); else if (r.outcome?.finalState === "GREEN") green.push(r.taskId); else if (r.outcome?.finalState === "UNVERIFIED_BY_CRITIC") unverified.push(r.taskId); @@ -203,16 +194,41 @@ export async function runGoal( }); } + // Integrate the per-task branches and run full verification on the result. + let integration: IntegrationResult | undefined; + if (wtManager) { + if (greenBranches.length > 0) { + const branchTaskIds = new Set(greenBranches.map((b) => b.taskId)); + const verifyCommands = [ + ...new Set( + plan.plan.tasks + .filter((t) => branchTaskIds.has(t.id)) + .flatMap((t) => t.verifyCommands), + ), + ]; + integration = await integrate({ + repoDir: repoDir as string, + branches: greenBranches, + ...(verifyCommands.length ? { verifyCommands } : {}), + ...(executor ? { executor } : {}), + ...(opts.log ? { log: opts.log } : {}), + }); + } + // Tear down any worktrees kept for integration. + for (const wt of wtManager.list()) await wtManager.release(wt.taskId); + } + return { goal, ...(sessionId ? { sessionId } : {}), plan, - results: ordered, + results, green, unverified, needsHuman, skipped, allVerified: green.length === plan.plan.tasks.length, + ...(integration ? { integration } : {}), }; } diff --git a/src/workspace/git.ts b/src/workspace/git.ts new file mode 100644 index 0000000..98c4ad3 --- /dev/null +++ b/src/workspace/git.ts @@ -0,0 +1,49 @@ +import { spawn } from "node:child_process"; + +/** + * Minimal injectable git transport. Worktree isolation (Task 20) and the + * integrator (Task 21) talk to git only through this, so they can be unit + * tested with a fake and exercised for real against a temp repo. + */ + +export interface GitResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export type GitExec = (args: string[], cwd: string) => Promise; + +/** Default GitExec: runs the real `git` binary (no shell) and captures output. */ +export const spawnGit: GitExec = (args, cwd) => + new Promise((resolve) => { + const child = spawn("git", args, { cwd, shell: false }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (b: Buffer) => (stdout += b.toString())); + child.stderr.on("data", (b: Buffer) => (stderr += b.toString())); + child.on("error", (err) => resolve({ stdout, stderr: String(err.message), exitCode: 127 })); + child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 })); + }); + +export class GitError extends Error { + constructor( + message: string, + readonly result: GitResult, + ) { + super(message); + this.name = "GitError"; + } +} + +/** Runs git and throws GitError on a non-zero exit, with captured output. */ +export async function git(exec: GitExec, args: string[], cwd: string): Promise { + const res = await exec(args, cwd); + if (res.exitCode !== 0) { + throw new GitError( + `git ${args.join(" ")} failed (exit ${res.exitCode}): ${res.stderr.trim() || res.stdout.trim()}`, + res, + ); + } + return res; +} diff --git a/src/workspace/worktrees.ts b/src/workspace/worktrees.ts new file mode 100644 index 0000000..6096582 --- /dev/null +++ b/src/workspace/worktrees.ts @@ -0,0 +1,119 @@ +import path from "node:path"; +import { rm } from "node:fs/promises"; +import { git, spawnGit, type GitExec } from "./git.js"; + +/** + * Isolated workspaces via git worktrees (Task 20). + * + * Concurrent tasks must not clobber each other's files (Req 9.2). Each task + * gets its own git worktree on a dedicated branch cut from a base ref, so it + * builds against a clean, independent checkout. After a task finishes, its + * changes are committed on that branch for the integrator to merge (Task 21), + * then the worktree directory is removed. + * + * git is injected so this is unit-testable with a fake and verifiable for real + * against a temp repository. + */ + +export interface Worktree { + taskId: string; + /** absolute path to the isolated checkout */ + path: string; + /** branch the worktree is on */ + branch: string; +} + +export interface WorktreeManagerOptions { + /** the source repository all worktrees branch from */ + repoDir: string; + /** namespaces branches/dirs so parallel sessions don't collide */ + sessionId: string; + /** base ref to branch each worktree from (default: HEAD) */ + baseRef?: string; + /** parent directory for worktree checkouts (default: /.loopwright/worktrees) */ + root?: string; + /** branch name prefix (default: "loopwright") */ + branchPrefix?: string; + git?: GitExec; +} + +/** Makes a filesystem/branch-safe slug from a task id. */ +function slug(taskId: string): string { + return taskId.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +export class GitWorktreeManager { + private readonly repoDir: string; + private readonly sessionId: string; + private readonly baseRef: string; + private readonly root: string; + private readonly branchPrefix: string; + private readonly exec: GitExec; + private readonly active = new Map(); + + constructor(opts: WorktreeManagerOptions) { + this.repoDir = opts.repoDir; + this.sessionId = opts.sessionId; + this.baseRef = opts.baseRef ?? "HEAD"; + this.root = opts.root ?? path.join(opts.repoDir, ".loopwright", "worktrees", opts.sessionId); + this.branchPrefix = opts.branchPrefix ?? "loopwright"; + this.exec = opts.git ?? spawnGit; + } + + /** Creates an isolated worktree + branch for a task and returns its path. */ + async acquire(taskId: string): Promise { + const existing = this.active.get(taskId); + if (existing) return existing; + + const branch = `${this.branchPrefix}/${this.sessionId}/${slug(taskId)}`; + const wtPath = path.join(this.root, slug(taskId)); + + // `-B` resets the branch if a stale one lingers from an aborted run, so + // re-running a task can't fail on "branch already exists". + await git(this.exec, ["worktree", "add", "-f", "-B", branch, wtPath, this.baseRef], this.repoDir); + + const wt: Worktree = { taskId, path: wtPath, branch }; + this.active.set(taskId, wt); + return wt; + } + + /** + * Commits everything in the worktree on its branch. Returns whether there was + * anything to commit (a no-op change set commits nothing). The committed + * branch is what the integrator merges. + */ + async commit(taskId: string, message: string): Promise<{ committed: boolean }> { + const wt = this.active.get(taskId); + if (!wt) throw new Error(`No active worktree for task "${taskId}".`); + await git(this.exec, ["add", "-A"], wt.path); + const status = await git(this.exec, ["status", "--porcelain"], wt.path); + if (status.stdout.trim() === "") return { committed: false }; + await git(this.exec, ["commit", "-m", message], wt.path); + return { committed: true }; + } + + /** Removes the worktree directory (and optionally deletes its branch). */ + async release(taskId: string, opts: { deleteBranch?: boolean } = {}): Promise { + const wt = this.active.get(taskId); + if (!wt) return; + try { + await git(this.exec, ["worktree", "remove", "--force", wt.path], this.repoDir); + } catch { + // best-effort: if git can't remove it (already gone), drop the dir directly + await rm(wt.path, { recursive: true, force: true }); + await this.exec(["worktree", "prune"], this.repoDir); + } + if (opts.deleteBranch) { + await this.exec(["branch", "-D", wt.branch], this.repoDir); + } + this.active.delete(taskId); + } + + branchFor(taskId: string): string | undefined { + return this.active.get(taskId)?.branch; + } + + list(): Worktree[] { + return [...this.active.values()]; + } +} diff --git a/test/integrator.test.ts b/test/integrator.test.ts new file mode 100644 index 0000000..977d1d5 --- /dev/null +++ b/test/integrator.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { integrate } from "../src/engine/integrator.js"; +import { GitWorktreeManager } from "../src/workspace/worktrees.js"; +import { spawnGit } from "../src/workspace/git.js"; +import { scriptedExecutor } from "../src/adapters/mocks.js"; + +async function initRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "loopwright-int-")); + const run = (args: string[]) => spawnGit(args, dir); + await run(["init", "-q", "-b", "main"]); + await run(["config", "user.email", "test@example.com"]); + await run(["config", "user.name", "Test"]); + await writeFile(join(dir, "base.txt"), "base\n"); + await run(["add", "-A"]); + await run(["commit", "-q", "-m", "init"]); + return dir; +} + +/** Builds a task branch by editing `file` to `content` and committing it. */ +async function makeBranch(repo: string, taskId: string, file: string, content: string): Promise { + const mgr = new GitWorktreeManager({ repoDir: repo, sessionId: "s" }); + const wt = await mgr.acquire(taskId); + await writeFile(join(wt.path, file), content); + await mgr.commit(taskId, `work ${taskId}`); + await mgr.release(taskId); // keeps the branch + return wt.branch; +} + +describe("integrate (real git)", () => { + let repo: string; + beforeEach(async () => { + repo = await initRepo(); + }); + + it("merges branches that touch different files and runs verification", async () => { + const ba = await makeBranch(repo, "a", "a.txt", "from a\n"); + const bb = await makeBranch(repo, "b", "b.txt", "from b\n"); + + const result = await integrate({ + repoDir: repo, + branches: [ + { taskId: "a", branch: ba }, + { taskId: "b", branch: bb }, + ], + verifyCommands: ["verify"], + executor: scriptedExecutor(() => ({ exitCode: 0, output: "ok" })), + }); + + expect(result.merged).toEqual(["a", "b"]); + expect(result.conflicts).toEqual([]); + expect(result.verification?.passed).toBe(true); + expect(result.ok).toBe(true); + + // both files exist on the integration branch + const show = await spawnGit(["ls-tree", "--name-only", result.integrationBranch], repo); + expect(show.stdout).toContain("a.txt"); + expect(show.stdout).toContain("b.txt"); + }); + + it("surfaces a conflict instead of merging it, and reports the file", async () => { + const ba = await makeBranch(repo, "a", "shared.txt", "version A\n"); + const bb = await makeBranch(repo, "b", "shared.txt", "version B\n"); + + const result = await integrate({ + repoDir: repo, + branches: [ + { taskId: "a", branch: ba }, + { taskId: "b", branch: bb }, + ], + }); + + expect(result.merged).toEqual(["a"]); // first merges clean + expect(result.conflicts).toHaveLength(1); // second conflicts + expect(result.conflicts[0]?.taskId).toBe("b"); + expect(result.conflicts[0]?.files).toContain("shared.txt"); + expect(result.ok).toBe(false); + }); + + it("marks ok=false when verification fails even with clean merges", async () => { + const ba = await makeBranch(repo, "a", "a.txt", "from a\n"); + const result = await integrate({ + repoDir: repo, + branches: [{ taskId: "a", branch: ba }], + verifyCommands: ["verify"], + executor: scriptedExecutor(() => ({ exitCode: 1, output: "tests failed" })), + }); + expect(result.merged).toEqual(["a"]); + expect(result.verification?.passed).toBe(false); + expect(result.ok).toBe(false); + }); +}); diff --git a/test/scheduler.test.ts b/test/scheduler.test.ts new file mode 100644 index 0000000..838676f --- /dev/null +++ b/test/scheduler.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import { runScheduledTasks, validateGraph, InvalidPlanGraphError } from "../src/engine/scheduler.js"; +import { loadConfig, type LoopwrightConfig } from "../src/config.js"; +import { MockActor, MockCritic, criticBlock, criticGreen } from "../src/adapters/mocks.js"; +import type { CommandExecutor } from "../src/engine/mechanicalGate.js"; +import type { TaskSpec } from "../src/schemas/plan.js"; + +const task = (id: string, dependencies: string[] = [], verify = ["check"]): TaskSpec => ({ + id, + title: id, + description: "", + acceptanceCriteria: ["x"], + verifyCommands: verify, + dependencies, +}); + +const cfg = (maxParallel: number): LoopwrightConfig => + loadConfig({ LOOPWRIGHT_MAX_PARALLEL: String(maxParallel) }); + +/** Records the order in which gate commands run. */ +function recordingExecutor(): { exec: CommandExecutor; order: string[] } { + const order: string[] = []; + const exec: CommandExecutor = async (command) => { + order.push(command); + return { exitCode: 0, output: "", durationMs: 1 }; + }; + return { exec, order }; +} + +/** Tracks the peak number of gate commands running at once. */ +function concurrencyExecutor(delayMs: number): { exec: CommandExecutor; max: () => number } { + let active = 0; + let peak = 0; + const exec: CommandExecutor = async () => { + active++; + peak = Math.max(peak, active); + await new Promise((r) => setTimeout(r, delayMs)); + active--; + return { exitCode: 0, output: "", durationMs: delayMs }; + }; + return { exec, max: () => peak }; +} + +const greenDeps = (config: LoopwrightConfig, executor: CommandExecutor) => ({ + actor: new MockActor({ plans: [] }), + critic: new MockCritic({ fallback: criticGreen() }), + config, + cwd: ".", + executor, +}); + +describe("validateGraph", () => { + it("rejects duplicate ids, missing deps, self-deps, and cycles", () => { + expect(() => validateGraph([task("a"), task("a")])).toThrow(InvalidPlanGraphError); + expect(() => validateGraph([task("a", ["ghost"])])).toThrow(/unknown task/); + expect(() => validateGraph([task("a", ["a"])])).toThrow(/itself/); + expect(() => validateGraph([task("a", ["b"]), task("b", ["a"])])).toThrow(/cycle/i); + }); + + it("accepts a valid DAG (diamond)", () => { + expect(() => + validateGraph([task("a"), task("b", ["a"]), task("c", ["a"]), task("d", ["b", "c"])]), + ).not.toThrow(); + }); +}); + +describe("runScheduledTasks ordering + concurrency", () => { + it("runs a dependency before its dependents", async () => { + const { exec, order } = recordingExecutor(); + const tasks = [ + task("a", [], ["check-a"]), + task("b", ["a"], ["check-b"]), + task("c", ["a"], ["check-c"]), + ]; + const results = await runScheduledTasks(tasks, greenDeps(cfg(2), exec)); + expect(results.every((r) => r.status === "completed")).toBe(true); + expect(order.indexOf("check-a")).toBeLessThan(order.indexOf("check-b")); + expect(order.indexOf("check-a")).toBeLessThan(order.indexOf("check-c")); + }); + + it("never exceeds the parallelism cap", async () => { + const { exec, max } = concurrencyExecutor(25); + const tasks = [task("a"), task("b"), task("c"), task("d")]; // all independent + await runScheduledTasks(tasks, greenDeps(cfg(2), exec)); + expect(max()).toBeLessThanOrEqual(2); + expect(max()).toBeGreaterThan(1); // actually ran in parallel + }); + + it("returns results in the input's declared order", async () => { + const { exec } = recordingExecutor(); + const tasks = [task("a"), task("b", ["a"]), task("c", ["a"])]; + const results = await runScheduledTasks(tasks, greenDeps(cfg(3), exec)); + expect(results.map((r) => r.taskId)).toEqual(["a", "b", "c"]); + }); +}); + +describe("runScheduledTasks failure propagation + resume", () => { + it("skips dependents (transitively) when a prerequisite fails", async () => { + const { exec } = recordingExecutor(); + const config = cfg(2); + const deps = { + actor: new MockActor({ plans: [] }), + // task "a" is always blocked -> NEEDS_HUMAN; others would be green + critic: new MockCritic({ + taskResponses: { + a: [criticBlock([{ severity: "blocker", category: "correctness", detail: "bug", location: "a" }])], + }, + fallback: criticGreen(), + }), + config, + cwd: ".", + executor: exec, + }; + const tasks = [task("a"), task("b", ["a"]), task("c", ["b"])]; + const results = await runScheduledTasks(tasks, deps); + + const byId = Object.fromEntries(results.map((r) => [r.taskId, r])); + expect(byId.a?.outcome?.finalState).toBe("NEEDS_HUMAN"); + expect(byId.b?.status).toBe("skipped"); + expect(byId.b?.blockedBy).toEqual(["a"]); + expect(byId.c?.status).toBe("skipped"); // transitively blocked via b + }); + + it("reuses a completed task via resumeOutcome without rebuilding", async () => { + const { exec, order } = recordingExecutor(); + const tasks = [task("a", [], ["check-a"])]; + const results = await runScheduledTasks(tasks, { + ...greenDeps(cfg(2), exec), + resumeOutcome: () => ({ + taskId: "a", + finalState: "GREEN", + verified: true, + history: [], + buildAttempts: 1, + reviewCycles: 0, + nits: [], + unresolvedBlockers: [], + lastDiff: "", + }), + }); + expect(results[0]?.status).toBe("resumed"); + // the gate never ran because the task was reused from the prior outcome + expect(order).toEqual([]); + }); +}); diff --git a/test/worktrees.test.ts b/test/worktrees.test.ts new file mode 100644 index 0000000..7655d49 --- /dev/null +++ b/test/worktrees.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, writeFile, readFile, stat } from "node:fs/promises"; +import { GitWorktreeManager } from "../src/workspace/worktrees.js"; +import { spawnGit } from "../src/workspace/git.js"; + +/** Initializes a throwaway git repo with one commit; returns its path. */ +async function initRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "loopwright-wt-")); + const run = (args: string[]) => spawnGit(args, dir); + await run(["init", "-q", "-b", "main"]); + await run(["config", "user.email", "test@example.com"]); + await run(["config", "user.name", "Test"]); + await writeFile(join(dir, "README.md"), "base\n"); + await run(["add", "-A"]); + await run(["commit", "-q", "-m", "init"]); + return dir; +} + +async function exists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +describe("GitWorktreeManager (real git)", () => { + let repo: string; + beforeEach(async () => { + repo = await initRepo(); + }); + + it("gives each task an isolated checkout on its own branch", async () => { + const mgr = new GitWorktreeManager({ repoDir: repo, sessionId: "s1" }); + const a = await mgr.acquire("task-a"); + const b = await mgr.acquire("task-b"); + + expect(a.path).not.toBe(b.path); + expect(await exists(a.path)).toBe(true); + expect(await exists(join(a.path, "README.md"))).toBe(true); + expect(a.branch).toContain("task-a"); + + // edits in one worktree don't appear in the other (isolation) + await writeFile(join(a.path, "only-a.txt"), "hi\n"); + expect(await exists(join(b.path, "only-a.txt"))).toBe(false); + }); + + it("commits a worktree's changes on its branch and reports whether anything changed", async () => { + const mgr = new GitWorktreeManager({ repoDir: repo, sessionId: "s1" }); + const wt = await mgr.acquire("task-a"); + + expect((await mgr.commit("task-a", "no changes")).committed).toBe(false); + + await writeFile(join(wt.path, "feature.txt"), "work\n"); + expect((await mgr.commit("task-a", "add feature")).committed).toBe(true); + + // the commit lives on the task branch + const log = await spawnGit(["log", "--oneline", wt.branch], repo); + expect(log.stdout).toContain("add feature"); + }); + + it("removes the worktree directory on release", async () => { + const mgr = new GitWorktreeManager({ repoDir: repo, sessionId: "s1" }); + const wt = await mgr.acquire("task-a"); + expect(await exists(wt.path)).toBe(true); + await mgr.release("task-a", { deleteBranch: true }); + expect(await exists(wt.path)).toBe(false); + expect(mgr.list()).toHaveLength(0); + }); +});