fix(agent-pod): stateless role pool (/new per reuse), never commit PLAN.md

This commit is contained in:
Story Crater Bot
2026-08-19 07:59:01 -07:00
parent 35bef19e0c
commit f6d25552f3
+57 -15
View File
@@ -19,8 +19,11 @@ data:
// 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
// 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
// every task it ever handles for that repo; nothing resets it mid-pipeline.
// agent its next task." Every role reads everything it needs fresh off disk
// 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
// phase run strictly sequentially against the pool -- no per-task worktree,
// 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"]);
// 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 =
"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. ";
@@ -215,11 +234,14 @@ data:
// 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
// later prompt into that same tmux pane via send-keys. pool is a plain
// object 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.
// later prompt into that same tmux pane via send-keys -- prefixed with a
// `/new` first, so the pane and agent-manager session stay the same but the
// model starts that prompt with a clean conversation, no history carried
// over from whatever task this role last handled. pool is a plain object
// 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) {
fs.rmSync(sentinelFile, { force: true });
const label = `${repoId}-${role}`;
@@ -235,6 +257,8 @@ data:
target = amSessionName(spawned.out);
pool[role] = target;
} else {
await runAmTmux(["send-keys", "-t", target, "/new", "Enter"]);
await sleep(1000);
await runAmTmux(["send-keys", "-t", target, HARD_RULES + prompt, "Enter"]);
}
@@ -257,14 +281,16 @@ data:
function plannerPrompt(task, specHint) {
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`
);
}
function investigatorPrompt(task) {
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`
);
}
@@ -291,9 +317,12 @@ data:
// PLAN.md, investigator confirms it, then implementer and judge go back and
// forth -- judge's FAIL rationale lands in .task-result-<task>, which the
// next implementer attempt is told to read and address. After
// MAX_IMPLEMENT_ATTEMPTS straight fails, the SAME planner agent is asked to
// judge whether the plan itself is wrong (it still remembers drafting it);
// if so it revises PLAN.md and the implementer gets a fresh attempt budget.
// MAX_IMPLEMENT_ATTEMPTS straight fails, the planner role is asked to judge
// whether the plan itself is wrong -- fresh conversation, same as any other
// 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
// genuinely stuck. All work happens directly in cwd (the repo's one shared
// clone, currently checked out to the phase branch) -- no worktree, since
@@ -326,6 +355,12 @@ data:
};
};
// PLAN.md is scratch state for this one task, not a deliverable (see
// plannerPrompt/investigatorPrompt -- it's gitignored too, as a backstop
// in case an agent commits it anyway). Discard it once the task is done,
// whatever the outcome, so it never bleeds into the next task's planner
// call or sits around as stale harness clutter in the shared clone.
try {
if (judgeOnly) {
const quick = await stage(
"judge",
@@ -389,9 +424,10 @@ data:
"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: ` +
`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"
@@ -409,6 +445,9 @@ data:
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 });
}
}
// Runs every task in a phase (no declared dependency between them) strictly
@@ -544,6 +583,9 @@ data:
".task-result-*",
".phase-result-*",
".stage-done-*",
"",
"# agent-harness: PLAN.md is per-task planner scratch state, never a deliverable",
"PLAN.md",
].join("\n");
fs.appendFileSync(path.join(cwd, ".gitignore"), gitignoreAdditions + "\n");
await runGit(cwd, ["add", ".gitignore"]);