Skip to content
Merged
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
74 changes: 54 additions & 20 deletions src/loop/main.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,55 @@
import { createInterface } from "node:readline/promises";
import { runDraftPrStep } from "./pr";
import { buildWorkPrompt } from "./prompts";
import { resolveReviewers, runReview } from "./review";
import { runAgent } from "./runner";
import type { Options } from "./types";
import { hasSignal } from "./utils";

export const runLoop = async (task: string, opts: Options): Promise<void> => {
const reviewers = resolveReviewers(opts.review, opts.agent);
const runIterations = async (
task: string,
opts: Options,
reviewers: string[],
hasExistingPr = false
): Promise<boolean> => {
let reviewNotes = "";

console.log(`\n[loop] PLAN.md:\n\n${task}`);

for (let iteration = 1; iteration <= opts.maxIterations; iteration++) {
const maxLabel = Number.isFinite(opts.maxIterations)
for (let i = 1; i <= opts.maxIterations; i++) {
const tag = Number.isFinite(opts.maxIterations)
? `/${opts.maxIterations}`
: "";
console.log(`\n[loop] iteration ${iteration}${maxLabel}`);

console.log(`\n[loop] iteration ${i}${tag}`);
const prompt = buildWorkPrompt(
task,
opts.doneSignal,
opts.proof,
reviewNotes
);
reviewNotes = "";

const result = await runAgent(opts.agent, prompt, opts);
if (result.exitCode !== 0) {
throw new Error(
`[loop] ${opts.agent} exited with code ${result.exitCode}`
);
}

const output = `${result.parsed}\n${result.combined}`;
if (!hasSignal(output, opts.doneSignal)) {
continue;
}

if (reviewers.length === 0) {
console.log(
`\n[loop] done signal "${opts.doneSignal}" detected, stopping.`
);
return;
return true;
}

const review = await runReview(reviewers, task, opts);
if (review.approved) {
await runDraftPrStep(task, opts);
await runDraftPrStep(task, opts, hasExistingPr);
console.log(
`\n[loop] done signal "${opts.doneSignal}" detected and review passed, stopping.`
);
return;
return true;
}

if (review.consensusFail) {
reviewNotes =
"Both reviewers requested changes. Decide for each comment whether to address it now. " +
Expand All @@ -62,12 +59,49 @@ export const runLoop = async (task: string, opts: Options): Promise<void> => {
);
continue;
}

reviewNotes = review.notes || "Reviewer found more work to do.";
console.log("\n[loop] review found more work. continuing loop.");
}
return false;
};

console.log(
`\n[loop] reached max iterations (${opts.maxIterations}), stopping.`
);
export const runLoop = async (task: string, opts: Options): Promise<void> => {
const reviewers = resolveReviewers(opts.review, opts.agent);
const interactive = process.stdin.isTTY || Boolean(process.env.TMUX);
const rl = interactive
? createInterface({ input: process.stdin, output: process.stdout })
: undefined;
let hasExistingPr = false;
let currentTask = task;
while (true) {
const done = await runIterations(
currentTask,
opts,
reviewers,
hasExistingPr
);
if (reviewers.length > 0 && done) {
hasExistingPr = true;
}
if (!rl) {
if (!done) {
console.log(
`\n[loop] reached max iterations (${opts.maxIterations}), stopping.`
);
}
return;
}
if (!done) {
console.log(`\n[loop] reached max iterations (${opts.maxIterations}).`);
}
const answer = await rl.question(
"\n[loop] follow-up prompt (blank to exit): "
);
const followUp = answer.trim() || null;
if (!followUp) {
rl.close();
return;
}
currentTask = `${currentTask}\n\nFollow-up:\n${followUp}`;
}
};
34 changes: 23 additions & 11 deletions src/loop/pr.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import { runAgent } from "./runner";
import type { Options } from "./types";

const buildDraftPrPrompt = (task: string): string =>
[
"Create a draft GitHub pull request for the current branch.",
`Task context:\n${task.trim()}`,
"Use `gh pr create --draft` with a clear title and description.",
"If a PR already exists for this branch, do not create another one.",
"Return the PR URL in your final response.",
].join("\n\n");
const buildDraftPrPrompt = (task: string, hasExistingPr: boolean): string =>
hasExistingPr
? [
"A PR already exists for this branch. Send a follow-up commit to it.",
`Task context:\n${task.trim()}`,
"Commit your follow-up changes, push them, and return the commit in your final response.",
].join("\n\n")
: [
"Create a draft GitHub pull request for the current branch.",
`Task context:\n${task.trim()}`,
"Use `gh pr create --draft` with a clear title and description.",
"If a PR already exists for this branch, do not create another one.",
"Return the PR URL in your final response.",
].join("\n\n");

export const runDraftPrStep = async (
task: string,
opts: Options
opts: Options,
hasExistingPr = false
): Promise<void> => {
console.log("\n[loop] review passed. asking model to create draft PR.");
const result = await runAgent(opts.agent, buildDraftPrPrompt(task), opts);
const action = hasExistingPr ? "send commit" : "create draft PR";
console.log(`\n[loop] review passed. asking model to ${action}.`);
const result = await runAgent(
opts.agent,
buildDraftPrPrompt(task, hasExistingPr),
opts
);
if (result.exitCode !== 0) {
throw new Error(
`[loop] draft PR ${opts.agent} exited with code ${result.exitCode}`
Expand Down
35 changes: 31 additions & 4 deletions src/loop/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cpSync, existsSync, readdirSync } from "node:fs";
import { cpSync, existsSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative } from "node:path";
import { buildLoopName, runGit, sanitizeBase } from "./git";
import type { Options } from "./types";
Expand All @@ -14,6 +14,7 @@ interface WorktreeDeps {
cwd: () => string;
env: NodeJS.ProcessEnv;
log: (line: string) => void;
moveFile: (source: string, target: string) => void;
pathExists: (path: string) => boolean;
runGit: (args: string[]) => GitResult;
syncTree: (source: string, target: string) => void;
Expand All @@ -22,9 +23,11 @@ interface WorktreeDeps {
const MAX_WORKTREE_ATTEMPTS = 10_000;
const RUN_BASE_ENV = "LOOP_RUN_BASE";
const RUN_ID_ENV = "LOOP_RUN_ID";
const PLAN_FILE = "PLAN.md";
const WORKTREE_CONFLICT_RE = /already exists|already checked out|not locked/i;
const STALE_WORKTREE_RE =
/missing but already registered worktree|already registered worktree/i;
const WORKTREE_GIT_DIR_MARKER = ".git/worktrees/";

const isInside = (root: string, path: string): boolean => {
const rel = relative(root, path);
Expand Down Expand Up @@ -103,6 +106,10 @@ const defaultDeps = (): WorktreeDeps => ({
console.log(line);
},
pathExists: (path: string) => existsSync(path),
moveFile: (source: string, target: string) => {
cpSync(source, target, { force: true });
rmSync(source, { force: true });
},
runGit: (args: string[]) => {
try {
return runGit(process.cwd(), args);
Expand Down Expand Up @@ -156,6 +163,25 @@ export const maybeEnterWorktree = (

const deps = { ...defaultDeps(), ...overrides };
const currentCwd = deps.cwd();
const superProject = deps.runGit([
"rev-parse",
"--show-superproject-working-tree",
]);
let inWorktree =
superProject.exitCode === 0 && Boolean(superProject.stdout.trim());

if (!inWorktree) {
const gitDir = deps.runGit(["rev-parse", "--git-dir"]).stdout.trim();
inWorktree = gitDir.replaceAll("\\", "/").includes(WORKTREE_GIT_DIR_MARKER);
}

if (inWorktree) {
deps.log(
"[loop] already running inside a git worktree, skipping --worktree setup"
);
return;
}
Comment thread
axeldelafosse marked this conversation as resolved.

const repoRoot = gitOutput(
deps,
["rev-parse", "--show-toplevel"],
Expand All @@ -171,9 +197,6 @@ export const maybeEnterWorktree = (
): { branch: string; worktreePath: string } | undefined => {
const candidateBranch = buildWorktreeBranch(runBase, index);
const candidatePath = buildWorktreePath(repoRoot, runBase, index);
if (deps.pathExists(candidatePath)) {
return undefined;
}

const branchExists =
deps.runGit([
Expand Down Expand Up @@ -244,6 +267,10 @@ export const maybeEnterWorktree = (
worktreePath,
deps.pathExists
);
const sourcePlanPath = join(currentCwd, PLAN_FILE);
if (deps.pathExists(sourcePlanPath)) {
deps.moveFile(sourcePlanPath, join(worktreeCwd, PLAN_FILE));
}
deps.chdir(worktreeCwd);
deps.log(`[loop] created worktree "${worktreePath}"`);
deps.log(`[loop] switched to branch "${branch}"`);
Expand Down
45 changes: 44 additions & 1 deletion tests/loop/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ const loadRunLoop = async (mocks: {
runAgent?: () => Promise<RunResult>;
runDraftPrStep?: () => Promise<undefined>;
runReview?: () => Promise<ReviewResult>;
question?: () => Promise<string>;
}) => {
mock.module("node:readline/promises", () => ({
createInterface: mock(() => ({
close: mock(() => undefined),
question: mock(async () => mocks.question?.() ?? ""),
})),
}));
mock.module("../../src/loop/prompts", () => ({
buildWorkPrompt: mock(mocks.buildWorkPrompt ?? (() => "prompt")),
}));
Expand Down Expand Up @@ -98,7 +105,43 @@ test("runLoop creates draft PR when done signal is reviewed and approved", async

expect(runAgent).toHaveBeenCalledTimes(1);
expect(runReview).toHaveBeenCalledTimes(1);
expect(runDraftPrStep).toHaveBeenCalledWith("Ship feature", opts);
expect(runDraftPrStep).toHaveBeenNthCalledWith(
1,
"Ship feature",
opts,
false
);
});

test("runLoop uses follow-up commit prompt after a PR is already created", async () => {
const answers = ["Update docs", ""];
const { runLoop, runAgent, runReview, runDraftPrStep } = await loadRunLoop({
resolveReviewers: () => ["codex", "claude"],
runAgent: async () => makeRunResult("<done/>"),
runReview: async () => ({
approved: true,
consensusFail: false,
notes: "",
}),
question: async () => answers.shift() ?? "",
});

await runLoop("Ship feature", makeOptions({ review: "claudex" }));

expect(runAgent).toHaveBeenCalledTimes(2);
expect(runReview).toHaveBeenCalledTimes(2);
expect(runDraftPrStep).toHaveBeenNthCalledWith(
1,
"Ship feature",
expect.any(Object),
false
);
expect(runDraftPrStep).toHaveBeenNthCalledWith(
2,
"Ship feature\n\nFollow-up:\nUpdate docs",
expect.any(Object),
true
);
});

test("runLoop forwards consensus review notes into the next iteration prompt", async () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/loop/pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ test("runDraftPrStep prompts model to create draft PR", async () => {
expect(passedOpts).toBe(opts);
});

test("runDraftPrStep prompts model to send a follow-up commit when PR exists", async () => {
const { runAgentMock, runDraftPrStep } = await loadRunDraftPrStep(
async () => ({
combined: "",
exitCode: 0,
parsed: "",
})
);

const opts = makeOptions();
await runDraftPrStep("Implement feature X", opts, true);

const [, prompt] = runAgentMock.mock.calls[0] as [
Options["agent"],
string,
Options,
];

expect(prompt).toContain("A PR already exists for this branch");
expect(prompt).toContain("follow-up commit");
expect(prompt).not.toContain("gh pr create --draft");
});

test("runDraftPrStep throws when model exits non-zero", async () => {
const { runDraftPrStep } = await loadRunDraftPrStep(async () => ({
combined: "",
Expand Down
1 change: 1 addition & 0 deletions tests/loop/tmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ test("runInTmux returns false when already inside tmux", () => {
test("runInTmux throws install message when tmux is missing", () => {
expect(() =>
runInTmux(["--tmux", "--proof", "verify"], {
env: {},
findBinary: () => false,
})
).toThrow(TMUX_MISSING_ERROR);
Expand Down
Loading