fix(agent-pod): group sessions by repo, resume phase branches, fix empty-diff bug

- agent-manager spawn now gets --group repoId, so the TUI clusters
  planner/investigator/implementer/judge under one repo heading instead of
  4 unrelated sessions.
- runPhase was called with phaseBranch where it needed the true baseBranch,
  so every per-task judge review compared phaseBranch...HEAD -- always
  empty, since HEAD is phaseBranch while checked out. Judges only produced
  real verdicts anyway because they fell back to their own git log/show.
- Every restart re-cloned baseBranch fresh and started a new phase branch,
  discarding whatever a prior run had already committed mid-phase. Now:
  fetch+resume an existing phase branch if origin has one, push after every
  task instead of only at phase-end, and delete the phase branch (local +
  origin) once its milestone squash-merges into base.
This commit is contained in:
Story Crater Bot
2026-08-19 11:46:53 -07:00
parent 19cc4062b4
commit f6de231999
+32 -6
View File
@@ -260,7 +260,7 @@ data:
const cwdReminder = `Your working directory for this task is ${cwd} -- if your shell isn't already there, run: cd ${cwd}\n\n`; const cwdReminder = `Your working directory for this task is ${cwd} -- if your shell isn't already there, run: cd ${cwd}\n\n`;
if (!target) { if (!target) {
const spawnArgs = ["spawn", "--tool", "pi", "--cwd", cwd, "--name", label, "--prompt", cwdReminder + HARD_RULES + prompt]; const spawnArgs = ["spawn", "--tool", "pi", "--cwd", cwd, "--name", label, "--group", repoId, "--prompt", cwdReminder + HARD_RULES + prompt];
const { provider, model } = providerModelFor(role); const { provider, model } = providerModelFor(role);
if (provider) spawnArgs.push("--provider", provider); if (provider) spawnArgs.push("--provider", provider);
if (model) spawnArgs.push("--model", model); if (model) spawnArgs.push("--model", model);
@@ -474,12 +474,21 @@ data:
// judge/etc. exists per repo, so there is no per-task concurrency to have // judge/etc. exists per repo, so there is no per-task concurrency to have
// here anymore (see REPO_CONCURRENCY below for where concurrency now // here anymore (see REPO_CONCURRENCY below for where concurrency now
// lives). No worktrees: every task commits directly onto phaseBranch in the // lives). No worktrees: every task commits directly onto phaseBranch in the
// one shared cwd. // one shared cwd. baseBranch here is the TRUE base (e.g. "main") -- judge
async function runPhase(cwd, baseBranch, phaseTasks, pool, repoId, pipelineSession) { // reviews `git diff baseBranch...HEAD`, not phaseBranch...HEAD, which would
// always be empty since HEAD *is* phaseBranch while it's checked out.
//
// Pushes phaseBranch after every task, not just once at full-phase-end: the
// pod is ephemeral and every restart re-clones baseBranch fresh (see
// runRepoPipeline) -- without this, a redeploy mid-phase silently discards
// every task committed so far, and the next run re-decides "is this done?"
// from a clone that never saw any of that work.
async function runPhase(cwd, baseBranch, phaseBranch, phaseTasks, pool, repoId, pipelineSession) {
const entries = phaseTasks.map((t) => (typeof t === "string" ? { id: t, judgeOnly: false } : t)); const entries = phaseTasks.map((t) => (typeof t === "string" ? { id: t, judgeOnly: false } : t));
for (const entry of entries) { for (const entry of entries) {
const result = await runTaskOnPool(cwd, baseBranch, entry.id, pool, repoId, pipelineSession, entry.judgeOnly); const result = await runTaskOnPool(cwd, baseBranch, entry.id, pool, repoId, pipelineSession, entry.judgeOnly);
pipelineSession.taskResults.push(result); pipelineSession.taskResults.push(result);
await runGit(cwd, ["push", "-u", "origin", phaseBranch]);
logProgress(pipelineSession); logProgress(pipelineSession);
} }
} }
@@ -584,13 +593,21 @@ data:
const phaseLabel = phaseLabelFor(phaseTasks, i); const phaseLabel = phaseLabelFor(phaseTasks, i);
const phaseBranch = branchName ? `${branchName}/${phaseLabel}` : `agent-run/${repoId}/${phaseLabel}`; const phaseBranch = branchName ? `${branchName}/${phaseLabel}` : `agent-run/${repoId}/${phaseLabel}`;
const branchResult = await runGit(cwd, ["checkout", "-b", phaseBranch]); // Resume a phase branch a prior (since-restarted) run already pushed,
// instead of always branching fresh off baseBranch -- otherwise every
// redeploy silently discards whatever tasks that prior run already
// committed and pushed (see runPhase's per-task push below).
const fetchExisting = await runGit(cwd, ["fetch", "origin", phaseBranch]);
const resuming = fetchExisting.code === 0;
const branchResult = resuming
? await runGit(cwd, ["checkout", "-b", phaseBranch, "FETCH_HEAD"])
: await runGit(cwd, ["checkout", "-b", phaseBranch]);
if (branchResult.code !== 0) { if (branchResult.code !== 0) {
pipelineSession.gitError = branchResult.out; pipelineSession.gitError = branchResult.out;
return finish("branch-crashed"); return finish("branch-crashed");
} }
if (i === 0) { if (i === 0 && !resuming) {
const gitignoreAdditions = [ const gitignoreAdditions = [
"", "",
"# agent-harness: build artifacts and vendored archives never belong in source control", "# agent-harness: build artifacts and vendored archives never belong in source control",
@@ -616,7 +633,7 @@ data:
await runGit(cwd, ["commit", "-m", "chore: broaden .gitignore for agent-run artifacts"]); await runGit(cwd, ["commit", "-m", "chore: broaden .gitignore for agent-run artifacts"]);
} }
await runPhase(cwd, phaseBranch, phaseTasks, pool, repoId, pipelineSession); await runPhase(cwd, baseBranch, phaseBranch, phaseTasks, pool, repoId, pipelineSession);
const phaseTaskIds = new Set(phaseTasks.map((t) => (typeof t === "string" ? t : t.id))); const phaseTaskIds = new Set(phaseTasks.map((t) => (typeof t === "string" ? t : t.id)));
const phaseResults = pipelineSession.taskResults.filter((r) => phaseTaskIds.has(r.task)); const phaseResults = pipelineSession.taskResults.filter((r) => phaseTaskIds.has(r.task));
@@ -680,6 +697,15 @@ data:
pipelineSession.gitError = push.out; pipelineSession.gitError = push.out;
return finish("squash-push-crashed"); return finish("squash-push-crashed");
} }
// Milestone's content now lives in baseBranch as one squashed commit --
// the phase branch (and whatever a prior restart already pushed of it)
// has no further reason to exist. Delete it both places so a future run
// never tries to resume a phase that's already done, and so origin
// doesn't accumulate one dangling branch per completed phase forever.
await runGit(cwd, ["branch", "-D", phaseBranch]);
await runGit(cwd, ["push", "origin", "--delete", phaseBranch]);
logProgress(pipelineSession); logProgress(pipelineSession);
} }