diff --git a/k8s/apps/agent-pod/coordinator-configmap.yaml b/k8s/apps/agent-pod/coordinator-configmap.yaml index 92552f3..9e550a6 100644 --- a/k8s/apps/agent-pod/coordinator-configmap.yaml +++ b/k8s/apps/agent-pod/coordinator-configmap.yaml @@ -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-, 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,89 +355,99 @@ data: }; }; - if (judgeOnly) { - const quick = await stage( - "judge", - `Task ${task} may already be implemented on this branch -- check ` + - `\`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 ` + - `verdict to .task-result-${task} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus ` + - `one line of rationale, then run: touch .stage-done-${task}-judge-0`, - path.join(cwd, `.stage-done-${task}-judge-0`) - ); - if (!quick.ok) return abandon("judge", quick, 0); - - 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 }; - } - } - - 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" + // 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", + `Task ${task} may already be implemented on this branch -- check ` + + `\`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 ` + + `verdict to .task-result-${task} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus ` + + `one line of rationale, then run: touch .stage-done-${task}-judge-0`, + path.join(cwd, `.stage-done-${task}-judge-0`) ); - if (!result.ok) return abandon("planner-revise", result, planRevisions); - implementAttempt = 0; - justRevisedPlan = true; + if (!quick.ok) return abandon("judge", quick, 0); + + 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]; - logProgress(pipelineSession); + let result = await stage("planner", plannerPrompt(task, specHint), path.join(cwd, `.stage-done-${task}-planner`)); + if (!result.ok) return abandon("planner", result); - if (verdict !== "PASS" && planRevisions >= MAX_PLAN_REVISIONS) { - return { task, status: "unresolved", judgeRationale: resultText, implementAttempts: implementAttempt, planRevisions }; + 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. 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 @@ -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"]);