fix(agent-pod): stateless role pool (/new per reuse), never commit PLAN.md
This commit is contained in:
@@ -19,8 +19,11 @@ data:
|
|||||||
// task to need a role spawns it, every later task for that role reuses the
|
// task to need a role spawns it, every later task for that role reuses the
|
||||||
// same tmux pane via `tmux send-keys` (see runOnPool) -- the same nudge
|
// same tmux pane via `tmux send-keys` (see runOnPool) -- the same nudge
|
||||||
// mechanism that used to only fire on a stall now doubles as "give this
|
// mechanism that used to only fire on a stall now doubles as "give this
|
||||||
// agent its next task." A role's conversation history accumulates across
|
// agent its next task." Every role reads everything it needs fresh off disk
|
||||||
// every task it ever handles for that repo; nothing resets it mid-pipeline.
|
// each call, so every pane gets a `/new` before every reuse instead of
|
||||||
|
// accumulating history that degrades and eventually errors out task after
|
||||||
|
// task -- same pane, same agent-manager session, zero memory of the last
|
||||||
|
// task it handled.
|
||||||
// Because only one implementer/judge/etc. exists per repo, tasks within a
|
// Because only one implementer/judge/etc. exists per repo, tasks within a
|
||||||
// phase run strictly sequentially against the pool -- no per-task worktree,
|
// phase run strictly sequentially against the pool -- no per-task worktree,
|
||||||
// no per-task branch, no merge-back step; every task commits straight onto
|
// no per-task branch, no merge-back step; every task commits straight onto
|
||||||
@@ -85,6 +88,22 @@ data:
|
|||||||
|
|
||||||
const ROLE_SKILLS = new Set(["planner", "investigator", "info-collector", "implementer", "judge", "resolver"]);
|
const ROLE_SKILLS = new Set(["planner", "investigator", "info-collector", "implementer", "judge", "resolver"]);
|
||||||
|
|
||||||
|
// Every role reads everything it needs fresh off disk each call -- PLAN.md,
|
||||||
|
// the task spec, judge's verdict file, `git diff` against baseBranch --
|
||||||
|
// nothing depends on remembering earlier tasks. Left to accumulate, a
|
||||||
|
// pooled session's conversation grows without bound across every task in a
|
||||||
|
// repo and both correctness and reliability degrade hard once it does
|
||||||
|
// (observed: a planner session at ~1.5M cumulative tokens started erroring
|
||||||
|
// out every call, an investigator session that far gone started narrating a
|
||||||
|
// different codebase entirely). So every role gets reset to a clean
|
||||||
|
// conversation before every reuse instead of just being nudged with the
|
||||||
|
// next prompt -- same pane, same agent-manager session (still
|
||||||
|
// visible/attachable), zero history carried between tasks.
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
const HARD_RULES =
|
const HARD_RULES =
|
||||||
"Read and follow ~/.pi/agent/skills/karpathy-guidelines/SKILL.md and " +
|
"Read and follow ~/.pi/agent/skills/karpathy-guidelines/SKILL.md and " +
|
||||||
"~/.pi/agent/skills/caveman/SKILL.md as hard rules for this entire task, before anything else. ";
|
"~/.pi/agent/skills/caveman/SKILL.md as hard rules for this entire task, before anything else. ";
|
||||||
@@ -215,11 +234,14 @@ data:
|
|||||||
|
|
||||||
// Runs one task's worth of work on a persistent per-role agent: spawns the
|
// Runs one task's worth of work on a persistent per-role agent: spawns the
|
||||||
// role's session the first time it's ever needed for this repo, sends every
|
// role's session the first time it's ever needed for this repo, sends every
|
||||||
// later prompt into that same tmux pane via send-keys. pool is a plain
|
// later prompt into that same tmux pane via send-keys -- prefixed with a
|
||||||
// object keyed by role name ("planner"/"investigator"/"implementer"/
|
// `/new` first, so the pane and agent-manager session stay the same but the
|
||||||
// "judge"), shared across every task in a repo's pipeline (see
|
// model starts that prompt with a clean conversation, no history carried
|
||||||
// runRepoPipeline) -- it IS the 4-agent pool, one entry per role, filled in
|
// over from whatever task this role last handled. pool is a plain object
|
||||||
// lazily as each role gets its first task.
|
// keyed by role name ("planner"/"investigator"/"implementer"/"judge"),
|
||||||
|
// shared across every task in a repo's pipeline (see runRepoPipeline) -- it
|
||||||
|
// IS the 4-agent pool, one entry per role, filled in lazily as each role
|
||||||
|
// gets its first task.
|
||||||
async function runOnPool(pool, cwd, repoId, role, prompt, sentinelFile) {
|
async function runOnPool(pool, cwd, repoId, role, prompt, sentinelFile) {
|
||||||
fs.rmSync(sentinelFile, { force: true });
|
fs.rmSync(sentinelFile, { force: true });
|
||||||
const label = `${repoId}-${role}`;
|
const label = `${repoId}-${role}`;
|
||||||
@@ -235,6 +257,8 @@ data:
|
|||||||
target = amSessionName(spawned.out);
|
target = amSessionName(spawned.out);
|
||||||
pool[role] = target;
|
pool[role] = target;
|
||||||
} else {
|
} else {
|
||||||
|
await runAmTmux(["send-keys", "-t", target, "/new", "Enter"]);
|
||||||
|
await sleep(1000);
|
||||||
await runAmTmux(["send-keys", "-t", target, HARD_RULES + prompt, "Enter"]);
|
await runAmTmux(["send-keys", "-t", target, HARD_RULES + prompt, "Enter"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,14 +281,16 @@ data:
|
|||||||
|
|
||||||
function plannerPrompt(task, specHint) {
|
function plannerPrompt(task, specHint) {
|
||||||
return (
|
return (
|
||||||
`Use the planner skill to draft PLAN.md for task ${task}, reading its spec (${specHint}). Commit PLAN.md. ` +
|
`Use the planner skill to draft PLAN.md for task ${task}, reading its spec (${specHint}). PLAN.md is scratch ` +
|
||||||
|
`state for this harness, not a deliverable -- do NOT commit it or add it to git. ` +
|
||||||
`Then run: touch .stage-done-${task}-planner`
|
`Then run: touch .stage-done-${task}-planner`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function investigatorPrompt(task) {
|
function investigatorPrompt(task) {
|
||||||
return (
|
return (
|
||||||
`Use the investigator skill to confirm PLAN.md against real sources for task ${task}, append findings, commit. ` +
|
`Use the investigator skill to confirm PLAN.md against real sources for task ${task}, append findings. ` +
|
||||||
|
`PLAN.md is scratch state for this harness, not a deliverable -- do NOT commit it or add it to git. ` +
|
||||||
`Then run: touch .stage-done-${task}-investigator`
|
`Then run: touch .stage-done-${task}-investigator`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -291,9 +317,12 @@ data:
|
|||||||
// PLAN.md, investigator confirms it, then implementer and judge go back and
|
// PLAN.md, investigator confirms it, then implementer and judge go back and
|
||||||
// forth -- judge's FAIL rationale lands in .task-result-<task>, which the
|
// forth -- judge's FAIL rationale lands in .task-result-<task>, which the
|
||||||
// next implementer attempt is told to read and address. After
|
// next implementer attempt is told to read and address. After
|
||||||
// MAX_IMPLEMENT_ATTEMPTS straight fails, the SAME planner agent is asked to
|
// MAX_IMPLEMENT_ATTEMPTS straight fails, the planner role is asked to judge
|
||||||
// judge whether the plan itself is wrong (it still remembers drafting it);
|
// whether the plan itself is wrong -- fresh conversation, same as any other
|
||||||
// if so it revises PLAN.md and the implementer gets a fresh attempt budget.
|
// planner call, reading PLAN.md/the judge feedback/the
|
||||||
|
// diff off disk rather than remembering having drafted the original plan.
|
||||||
|
// If it decides the approach is wrong it revises PLAN.md and the implementer
|
||||||
|
// gets a fresh attempt budget.
|
||||||
// MAX_PLAN_REVISIONS caps this from looping forever on a task that's
|
// MAX_PLAN_REVISIONS caps this from looping forever on a task that's
|
||||||
// genuinely stuck. All work happens directly in cwd (the repo's one shared
|
// genuinely stuck. All work happens directly in cwd (the repo's one shared
|
||||||
// clone, currently checked out to the phase branch) -- no worktree, since
|
// clone, currently checked out to the phase branch) -- no worktree, since
|
||||||
@@ -326,89 +355,99 @@ data:
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (judgeOnly) {
|
// PLAN.md is scratch state for this one task, not a deliverable (see
|
||||||
const quick = await stage(
|
// plannerPrompt/investigatorPrompt -- it's gitignored too, as a backstop
|
||||||
"judge",
|
// in case an agent commits it anyway). Discard it once the task is done,
|
||||||
`Task ${task} may already be implemented on this branch -- check ` +
|
// whatever the outcome, so it never bleeds into the next task's planner
|
||||||
`\`git log --oneline --grep '${task}'\` and the current code directly against its spec ` +
|
// call or sits around as stale harness clutter in the shared clone.
|
||||||
`(${specHint})'s acceptance criteria (no PLAN.md exists for this task yet). Write your ` +
|
try {
|
||||||
`verdict to .task-result-${task} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus ` +
|
if (judgeOnly) {
|
||||||
`one line of rationale, then run: touch .stage-done-${task}-judge-0`,
|
const quick = await stage(
|
||||||
path.join(cwd, `.stage-done-${task}-judge-0`)
|
"judge",
|
||||||
);
|
`Task ${task} may already be implemented on this branch -- check ` +
|
||||||
if (!quick.ok) return abandon("judge", quick, 0);
|
`\`git log --oneline --grep '${task}'\` and the current code directly against its spec ` +
|
||||||
|
`(${specHint})'s acceptance criteria (no PLAN.md exists for this task yet). Write your ` +
|
||||||
const quickText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
|
`verdict to .task-result-${task} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus ` +
|
||||||
if (parseVerdictLine(quickText, "VERDICT") === "PASS") {
|
`one line of rationale, then run: touch .stage-done-${task}-judge-0`,
|
||||||
delete pipelineSession.activeTasks[task];
|
path.join(cwd, `.stage-done-${task}-judge-0`)
|
||||||
logProgress(pipelineSession);
|
|
||||||
return { task, status: "done", judgeRationale: quickText, judgeOnlyPass: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = await stage("planner", plannerPrompt(task, specHint), path.join(cwd, `.stage-done-${task}-planner`));
|
|
||||||
if (!result.ok) return abandon("planner", result);
|
|
||||||
|
|
||||||
result = await stage("investigator", investigatorPrompt(task), path.join(cwd, `.stage-done-${task}-investigator`));
|
|
||||||
if (!result.ok) return abandon("investigator", result);
|
|
||||||
|
|
||||||
let planRevisions = 0;
|
|
||||||
let implementAttempt = 0;
|
|
||||||
let verdict = null;
|
|
||||||
let resultText = "";
|
|
||||||
let justRevisedPlan = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
implementAttempt++;
|
|
||||||
const feedbackHint = fs.existsSync(resultFile)
|
|
||||||
? justRevisedPlan
|
|
||||||
? `.task-result-${task} holds the judge's feedback against the OLD plan, which prompted a plan revision -- ` +
|
|
||||||
`PLAN.md has since changed. Read the current PLAN.md as the source of truth, not the old feedback verbatim.`
|
|
||||||
: `A previous judge review exists at .task-result-${task} -- read it and address every issue it raises.`
|
|
||||||
: "";
|
|
||||||
justRevisedPlan = false;
|
|
||||||
|
|
||||||
result = await stage(
|
|
||||||
"implementer",
|
|
||||||
implementerPrompt(task, implementAttempt, feedbackHint),
|
|
||||||
path.join(cwd, `.stage-done-${task}-implementer-${implementAttempt}`)
|
|
||||||
);
|
|
||||||
if (!result.ok) return abandon("implementer", result, implementAttempt);
|
|
||||||
|
|
||||||
result = await stage("judge", judgePrompt(task, baseBranch, implementAttempt), path.join(cwd, `.stage-done-${task}-judge-${implementAttempt}`));
|
|
||||||
if (!result.ok) return abandon("judge", result, implementAttempt);
|
|
||||||
|
|
||||||
resultText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
|
|
||||||
verdict = parseVerdictLine(resultText, "VERDICT");
|
|
||||||
if (verdict === "PASS") break;
|
|
||||||
|
|
||||||
if (implementAttempt >= MAX_IMPLEMENT_ATTEMPTS) {
|
|
||||||
if (planRevisions >= MAX_PLAN_REVISIONS) break;
|
|
||||||
planRevisions++;
|
|
||||||
result = await stage(
|
|
||||||
"planner",
|
|
||||||
`Implementer failed judge review ${MAX_IMPLEMENT_ATTEMPTS} times in a row for task ${task}. Read PLAN.md, ` +
|
|
||||||
`the judge's feedback in .task-result-${task}, and the current diff against ${baseBranch}...HEAD. Decide ` +
|
|
||||||
`whether the plan's approach itself is wrong, not just the implementation -- if so, revise PLAN.md and ` +
|
|
||||||
`commit. If you change the approach, also use the investigator skill to confirm the new approach against ` +
|
|
||||||
`real sources before committing. If the plan is sound, note why in PLAN.md and leave it as-is. Then run: ` +
|
|
||||||
`touch .stage-done-${task}-planner-revise-${planRevisions}`,
|
|
||||||
path.join(cwd, `.stage-done-${task}-planner-revise-${planRevisions}`),
|
|
||||||
"planner-revise"
|
|
||||||
);
|
);
|
||||||
if (!result.ok) return abandon("planner-revise", result, planRevisions);
|
if (!quick.ok) return abandon("judge", quick, 0);
|
||||||
implementAttempt = 0;
|
|
||||||
justRevisedPlan = true;
|
const quickText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
|
||||||
|
if (parseVerdictLine(quickText, "VERDICT") === "PASS") {
|
||||||
|
delete pipelineSession.activeTasks[task];
|
||||||
|
logProgress(pipelineSession);
|
||||||
|
return { task, status: "done", judgeRationale: quickText, judgeOnlyPass: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
delete pipelineSession.activeTasks[task];
|
let result = await stage("planner", plannerPrompt(task, specHint), path.join(cwd, `.stage-done-${task}-planner`));
|
||||||
logProgress(pipelineSession);
|
if (!result.ok) return abandon("planner", result);
|
||||||
|
|
||||||
if (verdict !== "PASS" && planRevisions >= MAX_PLAN_REVISIONS) {
|
result = await stage("investigator", investigatorPrompt(task), path.join(cwd, `.stage-done-${task}-investigator`));
|
||||||
return { task, status: "unresolved", judgeRationale: resultText, implementAttempts: implementAttempt, planRevisions };
|
if (!result.ok) return abandon("investigator", result);
|
||||||
|
|
||||||
|
let planRevisions = 0;
|
||||||
|
let implementAttempt = 0;
|
||||||
|
let verdict = null;
|
||||||
|
let resultText = "";
|
||||||
|
let justRevisedPlan = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
implementAttempt++;
|
||||||
|
const feedbackHint = fs.existsSync(resultFile)
|
||||||
|
? justRevisedPlan
|
||||||
|
? `.task-result-${task} holds the judge's feedback against the OLD plan, which prompted a plan revision -- ` +
|
||||||
|
`PLAN.md has since changed. Read the current PLAN.md as the source of truth, not the old feedback verbatim.`
|
||||||
|
: `A previous judge review exists at .task-result-${task} -- read it and address every issue it raises.`
|
||||||
|
: "";
|
||||||
|
justRevisedPlan = false;
|
||||||
|
|
||||||
|
result = await stage(
|
||||||
|
"implementer",
|
||||||
|
implementerPrompt(task, implementAttempt, feedbackHint),
|
||||||
|
path.join(cwd, `.stage-done-${task}-implementer-${implementAttempt}`)
|
||||||
|
);
|
||||||
|
if (!result.ok) return abandon("implementer", result, implementAttempt);
|
||||||
|
|
||||||
|
result = await stage("judge", judgePrompt(task, baseBranch, implementAttempt), path.join(cwd, `.stage-done-${task}-judge-${implementAttempt}`));
|
||||||
|
if (!result.ok) return abandon("judge", result, implementAttempt);
|
||||||
|
|
||||||
|
resultText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
|
||||||
|
verdict = parseVerdictLine(resultText, "VERDICT");
|
||||||
|
if (verdict === "PASS") break;
|
||||||
|
|
||||||
|
if (implementAttempt >= MAX_IMPLEMENT_ATTEMPTS) {
|
||||||
|
if (planRevisions >= MAX_PLAN_REVISIONS) break;
|
||||||
|
planRevisions++;
|
||||||
|
result = await stage(
|
||||||
|
"planner",
|
||||||
|
`Implementer failed judge review ${MAX_IMPLEMENT_ATTEMPTS} times in a row for task ${task}. Read PLAN.md, ` +
|
||||||
|
`the judge's feedback in .task-result-${task}, and the current diff against ${baseBranch}...HEAD. Decide ` +
|
||||||
|
`whether the plan's approach itself is wrong, not just the implementation -- if so, revise PLAN.md. If ` +
|
||||||
|
`you change the approach, also use the investigator skill to confirm the new approach against real ` +
|
||||||
|
`sources. If the plan is sound, note why in PLAN.md and leave it as-is. PLAN.md is scratch state for ` +
|
||||||
|
`this harness, not a deliverable -- do NOT commit it or add it to git. Then run: ` +
|
||||||
|
`touch .stage-done-${task}-planner-revise-${planRevisions}`,
|
||||||
|
path.join(cwd, `.stage-done-${task}-planner-revise-${planRevisions}`),
|
||||||
|
"planner-revise"
|
||||||
|
);
|
||||||
|
if (!result.ok) return abandon("planner-revise", result, planRevisions);
|
||||||
|
implementAttempt = 0;
|
||||||
|
justRevisedPlan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete pipelineSession.activeTasks[task];
|
||||||
|
logProgress(pipelineSession);
|
||||||
|
|
||||||
|
if (verdict !== "PASS" && planRevisions >= MAX_PLAN_REVISIONS) {
|
||||||
|
return { task, status: "unresolved", judgeRationale: resultText, implementAttempts: implementAttempt, planRevisions };
|
||||||
|
}
|
||||||
|
return { task, status: verdict === "PASS" ? "done" : "done-with-concerns", judgeRationale: resultText };
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(path.join(cwd, "PLAN.md"), { force: true });
|
||||||
}
|
}
|
||||||
return { task, status: verdict === "PASS" ? "done" : "done-with-concerns", judgeRationale: resultText };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs every task in a phase (no declared dependency between them) strictly
|
// Runs every task in a phase (no declared dependency between them) strictly
|
||||||
@@ -544,6 +583,9 @@ data:
|
|||||||
".task-result-*",
|
".task-result-*",
|
||||||
".phase-result-*",
|
".phase-result-*",
|
||||||
".stage-done-*",
|
".stage-done-*",
|
||||||
|
"",
|
||||||
|
"# agent-harness: PLAN.md is per-task planner scratch state, never a deliverable",
|
||||||
|
"PLAN.md",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
fs.appendFileSync(path.join(cwd, ".gitignore"), gitignoreAdditions + "\n");
|
fs.appendFileSync(path.join(cwd, ".gitignore"), gitignoreAdditions + "\n");
|
||||||
await runGit(cwd, ["add", ".gitignore"]);
|
await runGit(cwd, ["add", ".gitignore"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user