cubefarm 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-B7sInPPW.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-g6QWWXij.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CIr98Zaa.css">
13
13
  </head>
14
14
  <body>
@@ -194,8 +194,10 @@ async function prForBranch(fullName, branch) {
194
194
  const list = await ghJson(["pr", "list", "-R", fullName, "--head", branch, "--state", "all", "--json", "number,url", "--limit", "1"]);
195
195
  return list[0] ?? null;
196
196
  }
197
+ var uniqueNames = (checks) => [...new Set(checks.map(checkName))];
197
198
  async function prDetails(fullName, number) {
198
- const raw = await ghJson(["pr", "view", String(number), "-R", fullName, "--json", "number,title,body,url,headRefName,headRefOid,isCrossRepository,closingIssuesReferences,state,mergeable,mergeStateStatus"]);
199
+ const raw = await ghJson(["pr", "view", String(number), "-R", fullName, "--json", "number,title,body,url,headRefName,headRefOid,isCrossRepository,closingIssuesReferences,state,mergeable,mergeStateStatus,statusCheckRollup"]);
200
+ const rollup = raw.statusCheckRollup ?? [];
199
201
  return {
200
202
  number: raw.number,
201
203
  title: raw.title,
@@ -207,7 +209,11 @@ async function prDetails(fullName, number) {
207
209
  closesIssues: (raw.closingIssuesReferences ?? []).map((r) => r.number),
208
210
  state: raw.state,
209
211
  mergeable: raw.mergeable,
210
- mergeState: raw.mergeStateStatus || "UNKNOWN"
212
+ mergeState: raw.mergeStateStatus || "UNKNOWN",
213
+ checks: checksOf(rollup),
214
+ checkNames: uniqueNames(rollup),
215
+ failedChecks: uniqueNames(rollup.filter(failed)),
216
+ pendingChecks: uniqueNames(rollup.filter(pending))
211
217
  };
212
218
  }
213
219
  async function issueDetails(fullName, number) {
@@ -457,7 +463,32 @@ async function createProject(root, name, opts) {
457
463
  async function removeDir(dir) {
458
464
  await fs2.rm(dir, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 });
459
465
  }
460
- function prepareDesk(fullName, base, agentSlug, branch) {
466
+ var STALE_LOCK_MS = 10 * 6e4;
467
+ async function staleIndexLock(wt) {
468
+ const ref = (await fs2.readFile(path2.join(wt, ".git"), "utf8").catch(() => "")).match(/gitdir:\s*(.+)/)?.[1]?.trim();
469
+ if (!ref) return null;
470
+ const lock = path2.join(path2.resolve(wt, ref), "index.lock");
471
+ const stat = await fs2.stat(lock).catch(() => null);
472
+ return stat && Date.now() - stat.mtimeMs > STALE_LOCK_MS ? lock : null;
473
+ }
474
+ async function reuseDesk(wt, branch, ref) {
475
+ await git(["checkout", "--force", "-B", branch, ref], { cwd: wt });
476
+ await git(["reset", "--hard", ref], { cwd: wt });
477
+ await git(["clean", "-fd"], { cwd: wt }).catch(() => void 0);
478
+ }
479
+ async function clearDeskFolder(wt) {
480
+ try {
481
+ await removeDir(wt);
482
+ } catch (err) {
483
+ const left = await fs2.readdir(wt).catch(() => null);
484
+ if (left?.length !== 0) {
485
+ throw new Error(
486
+ `Could not clear the desk folder ${wt}: ${err.message}. A program started by the previous task is probably still running there. Close it and try again.`
487
+ );
488
+ }
489
+ }
490
+ }
491
+ function prepareDesk(fullName, base, agentSlug, branch, note) {
461
492
  return withRepoLock(fullName, async () => {
462
493
  const main = mainDir(fullName);
463
494
  await git(["fetch", "origin", "--prune"], { cwd: main, timeoutMs: 18e4 });
@@ -476,22 +507,25 @@ function prepareDesk(fullName, base, agentSlug, branch) {
476
507
  const wt = deskDir(fullName, agentSlug);
477
508
  if (await exists(path2.join(wt, ".git"))) {
478
509
  try {
479
- await git(["checkout", "--force", "-B", branch, ref], { cwd: wt });
480
- await git(["reset", "--hard", ref], { cwd: wt });
481
- await git(["clean", "-fd"], { cwd: wt }).catch(() => void 0);
510
+ try {
511
+ await reuseDesk(wt, branch, ref);
512
+ } catch (err) {
513
+ const lock = /index\.lock/.test(err.message) ? await staleIndexLock(wt) : null;
514
+ if (!lock) throw err;
515
+ await fs2.rm(lock, { force: true, maxRetries: 3 });
516
+ note?.(`Removed a stale git lock (${lock}) left in the desk.`);
517
+ await reuseDesk(wt, branch, ref);
518
+ }
482
519
  return wt;
483
- } catch {
520
+ } catch (err) {
521
+ const why = `Couldn't reuse the desk in place, so it's rebuilt: ${err.message}`;
522
+ console.warn(`${wt}: ${why}`);
523
+ note?.(why);
484
524
  }
485
525
  }
486
526
  if (await exists(wt)) {
487
527
  await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
488
- try {
489
- await removeDir(wt);
490
- } catch (err) {
491
- throw new Error(
492
- `Could not clear the desk folder ${wt}: ${err.message}. A program started by the previous task is probably still running there. Close it and try again.`
493
- );
494
- }
528
+ await clearDeskFolder(wt);
495
529
  }
496
530
  await git(["worktree", "prune"], { cwd: main });
497
531
  await fs2.mkdir(path2.dirname(wt), { recursive: true });
@@ -1702,12 +1736,13 @@ function endLive(live) {
1702
1736
  routes.delete(live.token);
1703
1737
  live.term.releaseIdle = null;
1704
1738
  live.term.bind(null);
1705
- const gone = live.proc ? exitOf(live.proc) : void 0;
1739
+ const gone = live.proc ? exitOf(live.proc) : Promise.resolve();
1706
1740
  live.proc?.kill();
1707
1741
  live.proc = null;
1708
1742
  live.term.note(`\u2500\u2500 ${cliLabel(live.cli)} session ended \u2500\u2500`);
1709
1743
  fs7.rm(live.dir, { recursive: true, force: true, maxRetries: 5 }, () => void 0);
1710
- return live.cli === "codex" && live.resumeId ? codexThread("archive", live.resumeId, gone) : Promise.resolve();
1744
+ if (live.cli === "codex" && live.resumeId) void codexThread("archive", live.resumeId, gone);
1745
+ return gone;
1711
1746
  }
1712
1747
  var metaOf = (l) => ({ agentId: l.agentId, cli: l.cli, token: l.token, dir: l.dir, resumeId: l.resumeId, busy: !!l.session });
1713
1748
  var remember = (l) => l.proc?.setMeta(metaOf(l));
@@ -2551,18 +2586,25 @@ function devScript(opts, cb, issueNumber, issueTitle) {
2551
2586
  [{ kind: "tool", tool: "Bash", text: `\u23FA $ gh pr create --title "${issueTitle}" --body "Closes #${issueNumber}"` }]
2552
2587
  ];
2553
2588
  }
2554
- function qaScript(cb, pr, title, round) {
2589
+ function qaScript(cb, pr, title, round, checks) {
2555
2590
  const port = 5600 + pr % 50;
2556
2591
  const hue = pr * 41 % 360;
2557
- return [
2558
- [{ kind: "text", text: `\u25CF Testing PR #${pr} "${title}" (round ${round}). First, the acceptance criteria from the issue.` }],
2559
- [{ kind: "tool", tool: "Bash", text: "\u23FA $ git diff origin/main...HEAD --stat" }, { kind: "result", text: " \u23BF 3 files changed, 82 insertions(+), 9 deletions(-)" }],
2592
+ const verify = checks.endsWith(": none") ? [
2560
2593
  [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm ci && npm test -- --run" }],
2561
2594
  [
2562
2595
  { kind: "result", text: " \u23BF Test Files 8 passed (8)" },
2563
2596
  { kind: "result", text: " Tests 41 passed (41)" }
2564
2597
  ],
2565
- [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm run lint && npm run build" }, { kind: "result", text: " \u23BF \u2713 built in 1.84s" }],
2598
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm run lint && npm run build" }, { kind: "result", text: " \u23BF \u2713 built in 1.84s" }]
2599
+ ] : [
2600
+ [{ kind: "tool", tool: "Read", text: "\u23FA Read .github/workflows/ci.yml" }, { kind: "result", text: " \u23BF Read 41 lines" }],
2601
+ [{ kind: "text", text: "\u25CF CI already runs the tests, lint and build, so I only need a build to start the app." }],
2602
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm ci && npm run build" }, { kind: "result", text: " \u23BF \u2713 built in 1.84s" }]
2603
+ ];
2604
+ return [
2605
+ [{ kind: "text", text: `\u25CF Testing PR #${pr} "${title}" (round ${round}). ${checks}. First, the acceptance criteria from the issue.` }],
2606
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ git diff origin/main...HEAD --stat" }, { kind: "result", text: " \u23BF 3 files changed, 82 insertions(+), 9 deletions(-)" }],
2607
+ ...verify,
2566
2608
  [{ kind: "tool", tool: "Bash", text: `\u23FA $ npm run preview -- --port ${port} &` }, { kind: "result", text: ` \u23BF Local: http://localhost:${port}/` }],
2567
2609
  () => cb.browserUrl(`http://localhost:${port}/`),
2568
2610
  [{ kind: "tool", tool: "mcp__playwright__browser_navigate", text: `\u23FA \u{1F310} navigate http://localhost:${port}/` }, { kind: "result", text: ` \u23BF Page URL: http://localhost:${port}/` }],
@@ -2598,7 +2640,8 @@ function fakeSession(opts, cb, fullName) {
2598
2640
  { kind: "system", text: `\u273B Claude Code (demo) \xB7 ${opts.model} \xB7 ${opts.effort} effort` },
2599
2641
  { kind: "system", text: ` cwd ${opts.cwd}` }
2600
2642
  ];
2601
- const body = kind === "qa" ? qaScript(cb, number, title, round) : kind === "fix" ? fixScript(number, pushes) : devScript(opts, cb, number, title);
2643
+ const checks = opts.prompt.match(/^GitHub checks right now: .*$/m)?.[0] ?? "GitHub checks right now: none";
2644
+ const body = kind === "qa" ? qaScript(cb, number, title, round, checks) : kind === "fix" ? fixScript(number, pushes) : devScript(opts, cb, number, title);
2602
2645
  const script = [header, ...body];
2603
2646
  const finish = () => {
2604
2647
  const costUsd = 0.3 + Math.random();
@@ -2887,7 +2930,11 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
2887
2930
  closesIssues: pr.closesIssues,
2888
2931
  state: pr.state,
2889
2932
  mergeable: pr.mergeable,
2890
- mergeState: pr.mergeState
2933
+ mergeState: pr.mergeState,
2934
+ checks: pr.checks,
2935
+ checkNames: pr.checks === "none" ? [] : ["CI / build", "Vercel"],
2936
+ failedChecks: pr.failedChecks.map((c) => c.name),
2937
+ pendingChecks: pr.pendingChecks
2891
2938
  };
2892
2939
  },
2893
2940
  issueDetails: async (fullName, number) => {
@@ -3649,6 +3696,13 @@ function specialtySlug(s) {
3649
3696
  }
3650
3697
  var specialtyLabel = (slug) => `swarm:${slug}`;
3651
3698
 
3699
+ // server/deskSetup.ts
3700
+ async function setUpDesk(term, steps) {
3701
+ await term?.releaseIdle?.();
3702
+ await steps.release();
3703
+ return steps.prepare();
3704
+ }
3705
+
3652
3706
  // server/fixOutcome.ts
3653
3707
  var MAX_FIX_FAILURES = 2;
3654
3708
  function fixOutcome(rec, head, minutes, counts = true) {
@@ -3663,7 +3717,7 @@ function fixOutcome(rec, head, minutes, counts = true) {
3663
3717
  pushed: false
3664
3718
  };
3665
3719
  }
3666
- if (rec.fixReason === "checks" || rec.fixReason === "conflict") {
3720
+ if (rec.fixReason === "checks" || rec.fixReason === "conflict" && rec.passedSha) {
3667
3721
  return { set: { status: "passed", sessionFailures: 0, mergeNote: "waiting for fresh checks" }, log: { kind: "done", text: `\u2714 PR #${pr} fixed in ${minutes}m. Back in line to merge.` }, pushed: true };
3668
3722
  }
3669
3723
  return { set: { status: "queued", round: rec.round + 1, sessionFailures: 0 }, log: { kind: "done", text: `\u2714 Fix pushed for PR #${pr} in ${minutes}m. Back to QA.` }, pushed: true };
@@ -3736,6 +3790,57 @@ function orphanedQa(qa, agents, busy) {
3736
3790
  );
3737
3791
  }
3738
3792
 
3793
+ // server/qaOutcome.ts
3794
+ var MAX_QA_ROUNDS = 3;
3795
+ function lastQaRound(rec) {
3796
+ return rec.round - rec.retests >= MAX_QA_ROUNDS;
3797
+ }
3798
+ function qaOutcome(pass, rec, pr) {
3799
+ if (pass) return "passed";
3800
+ if (!lastQaRound(rec)) return "failed";
3801
+ const conflicting = pr != null && (pr.mergeable === "CONFLICTING" || pr.mergeState === "DIRTY");
3802
+ return conflicting && rec.mergeFixes < MAX_MERGE_FIXES ? "conflict" : "needs-human";
3803
+ }
3804
+ function conflictFixInstructions(qaInstructions2, base) {
3805
+ const merge = `It also conflicts with ${base}: bring it up to date (git fetch origin && git merge origin/${base}) and resolve the conflicts so both this change and the newly merged work keep working.`;
3806
+ return qaInstructions2.trim() ? `${qaInstructions2.trim()}
3807
+
3808
+ ${merge}` : merge;
3809
+ }
3810
+
3811
+ // server/qaPrompt.ts
3812
+ function qaInstructions(i) {
3813
+ return { checks: checksLine(i), testStep: testStep(i.checks !== "none"), retest: retest(i) };
3814
+ }
3815
+ function checksLine(i) {
3816
+ const names = (list) => list.join(", ") || "unnamed";
3817
+ const state = i.checks === "passing" ? `passing (${names(i.checkNames)})` : i.checks === "failing" ? `failing: ${names(i.failedChecks)}${i.pendingChecks.length ? `; still running: ${i.pendingChecks.join(", ")}` : ""}` : i.checks === "pending" ? `pending: ${names(i.pendingChecks)}` : "none";
3818
+ return `GitHub checks right now: ${state}`;
3819
+ }
3820
+ function testStep(hasChecks) {
3821
+ return hasChecks ? "3. GitHub's checks run on this PR, and it merges only once they're green. Read .github/workflows to see what they cover and don't re-run that locally. Run only what you need to exercise the change (for example a build to start the app), anything the checks don't cover, or a failing check to reproduce it. A failing check is a finding: name it." : "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).";
3822
+ }
3823
+ function retest(i) {
3824
+ const since = i.lastTestedSha && i.lastTestedSha !== i.headSha ? i.lastTestedSha : null;
3825
+ const diff = since ? `(git diff ${since}..HEAD), plus a quick smoke test of the main flow. Don't repeat the full test plan for parts the change didn't touch.` : null;
3826
+ if (i.fixReason === "conflict") {
3827
+ return `
3828
+ QA passed it before, but since then the branch was updated with ${i.defaultBranch} to resolve merge conflicts. Re-check everything, especially where this change meets the newly merged work.`;
3829
+ }
3830
+ if (i.fixReason === "checks") {
3831
+ return `
3832
+ QA passed it before, but since then the developer changed the code to fix failing GitHub checks. ${diff ? `Test what changed since QA passed it ${diff}` : "Re-check everything."}`;
3833
+ }
3834
+ if (i.round > 1 && i.summary) {
3835
+ return `
3836
+ This is a re-test after fixes. Last round's findings:
3837
+ ${i.summary}
3838
+ ${i.fixInstructions ?? ""}
3839
+ Check those first, then ${diff ? `test what changed since the last round ${diff}` : "re-check everything else."}`;
3840
+ }
3841
+ return "";
3842
+ }
3843
+
3739
3844
  // server/restartRecovery.ts
3740
3845
  function followKeptCli(a, cli) {
3741
3846
  if (!cli?.busy || a.status === "preparing") return false;
@@ -3999,7 +4104,7 @@ ${links.join("\n")}` : "",
3999
4104
  "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
4000
4105
  ].filter((l) => l !== "").join("\n");
4001
4106
  }
4002
- function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr }) {
4107
+ function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr, testStep: testStep2 }) {
4003
4108
  return [
4004
4109
  `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
4005
4110
  ...a.brief ? ["", `Your job description:
@@ -4017,9 +4122,10 @@ ${repo.qaBrief}`] : [],
4017
4122
  "How to test:",
4018
4123
  "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
4019
4124
  `2. Review the code as a careful reviewer would: git diff origin/${repo.defaultBranch}...HEAD. Look for bugs, unhandled errors and edge cases, security problems, leftover debug code, and new logic without tests.`,
4020
- "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
4125
+ testStep2 ?? "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
4021
4126
  repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${port} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
4022
4127
  "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
4128
+ "A merge conflict with the default branch is not a fail: judge the change itself (on a throwaway merge if you need newer work) and pass it if it's good. The office sends conflicting PRs back for a merge fix, then you re-test.",
4023
4129
  "",
4024
4130
  "Rules: do not modify the code under test, do not commit, push, comment on, review or merge anything on GitHub. The office posts your report on the pull request. Finish with the structured QA report: verdict, summary, the checks you performed, the commands you ran and one caption per screenshot."
4025
4131
  ].join("\n");
@@ -4105,7 +4211,10 @@ var AgentTerminal = class {
4105
4211
  rows = TERM_ROWS;
4106
4212
  /** Output arrived since the last save. */
4107
4213
  dirty = false;
4108
- /** Closes a CLI left waiting at its prompt after its task (set by the runtime that left it there). */
4214
+ /**
4215
+ * Closes a CLI left waiting at its prompt after its task, settling once it has exited (set by the runtime that left
4216
+ * it there; null while a CLI drives a session).
4217
+ */
4109
4218
  releaseIdle = null;
4110
4219
  /** Something the manager typed at that waiting prompt: the office takes it on as a follow-up (true) or refuses. */
4111
4220
  onIdlePrompt = null;
@@ -4316,7 +4425,6 @@ var FEMININE_NAMES = new Set(
4316
4425
  var lookFor = (name) => FEMININE_NAMES.has(name.trim().split(/\s+/)[0].toLowerCase()) ? "feminine" : "masculine";
4317
4426
  var LOOKS = ["feminine", "masculine"];
4318
4427
  var MAX_DESKS = { ...FLOOR_DESKS, ceo: 1 };
4319
- var MAX_QA_ROUNDS = 3;
4320
4428
  var DEFAULT_MODEL = "claude-opus-5-5";
4321
4429
  var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
4322
4430
  var CEO_MODEL = "claude-opus-5-5";
@@ -4481,6 +4589,8 @@ var Swarm = class {
4481
4589
  messageSeq = 1;
4482
4590
  agentRt = /* @__PURE__ */ new Map();
4483
4591
  repoRt = /* @__PURE__ */ new Map();
4592
+ deskAlerts = /* @__PURE__ */ new Map();
4593
+ // agent id -> the desk setup error the manager was last told about
4484
4594
  clients = /* @__PURE__ */ new Set();
4485
4595
  user = null;
4486
4596
  ghError;
@@ -5560,8 +5670,13 @@ var Swarm = class {
5560
5670
  async prepare(a, repo, base, branch) {
5561
5671
  try {
5562
5672
  if (this.repoRt.get(repo.id)?.cloneStatus !== "ready") await this.cloneRepo(repo.id);
5563
- await this.backend.releaseDesk(repo.fullName, this.agentSlug(a), this.port(a));
5564
- const cwd = await this.backend.prepareDesk(repo.fullName, { defaultBranch: repo.defaultBranch, pr: base.pr }, this.agentSlug(a), branch);
5673
+ const slug = this.agentSlug(a);
5674
+ const note = (text) => this.appendLog(a, [{ kind: "system", text }]);
5675
+ const cwd = await setUpDesk(this.agentRt.get(a.id)?.terminal, {
5676
+ release: () => this.backend.releaseDesk(repo.fullName, slug, this.port(a)),
5677
+ prepare: () => this.backend.prepareDesk(repo.fullName, { defaultBranch: repo.defaultBranch, pr: base.pr }, slug, branch, note)
5678
+ });
5679
+ this.deskAlerts.delete(a.id);
5565
5680
  return a.status === "preparing" ? cwd : null;
5566
5681
  } catch (err) {
5567
5682
  if (a.status !== "preparing") return null;
@@ -5569,6 +5684,10 @@ var Swarm = class {
5569
5684
  a.endedAt = Date.now();
5570
5685
  a.lastError = err.message;
5571
5686
  this.appendLog(a, [{ kind: "error", text: `\u2717 ${a.lastError}` }]);
5687
+ if (this.deskAlerts.get(a.id) !== a.lastError) {
5688
+ this.deskAlerts.set(a.id, a.lastError);
5689
+ this.postMessage("office", `\u26A0\uFE0F ${a.name}'s desk couldn't be set up: ${a.lastError.slice(0, 240)}`);
5690
+ }
5572
5691
  this.emitAgent(a);
5573
5692
  this.save();
5574
5693
  return null;
@@ -5776,8 +5895,8 @@ Note from the manager: ${note}` : ""
5776
5895
  this.queueQa(repo, prNumber, dev ?? null, pr.closesIssues[0] ?? null);
5777
5896
  this.toast("info", `PR #${prNumber} is queued for QA`);
5778
5897
  }
5779
- buildQaSystemAppend(a, repo, cwd, branch, pr) {
5780
- return qaSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, pr });
5898
+ buildQaSystemAppend(a, repo, cwd, branch, pr, testStep2) {
5899
+ return qaSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, pr, testStep: testStep2 });
5781
5900
  }
5782
5901
  async runQa(a, repo, rec) {
5783
5902
  const branch = qaBranch(rec.prNumber, slugify(a.name));
@@ -5790,6 +5909,7 @@ Note from the manager: ${note}` : ""
5790
5909
  );
5791
5910
  let pr;
5792
5911
  let issue2 = null;
5912
+ const lastTestedSha = rec.testedSha;
5793
5913
  try {
5794
5914
  pr = await this.backend.prDetails(repo.fullName, rec.prNumber);
5795
5915
  const issueNumber = rec.issueNumber ?? pr.closesIssues[0] ?? null;
@@ -5819,16 +5939,22 @@ Note from the manager: ${note}` : ""
5819
5939
  return;
5820
5940
  }
5821
5941
  const dev = rec.devAgentId ? this.state.agents.find((x) => x.id === rec.devAgentId) : null;
5942
+ const failedConflict = rec.fixReason === "conflict" && !rec.passedSha;
5943
+ const qa = qaInstructions({
5944
+ ...pr,
5945
+ round: rec.round,
5946
+ fixReason: failedConflict ? "qa" : rec.fixReason,
5947
+ lastTestedSha: failedConflict ? null : lastTestedSha,
5948
+ summary: rec.summary,
5949
+ fixInstructions: rec.fixInstructions,
5950
+ defaultBranch: repo.defaultBranch
5951
+ });
5822
5952
  const prompt = [
5823
5953
  `Please QA pull request #${pr.number}: ${pr.title}`,
5824
5954
  `URL: ${pr.url}`,
5825
5955
  `Author: ${dev ? `${dev.name} (developer agent)` : "a teammate"} \xB7 QA round ${rec.round}`,
5826
- rec.fixReason === "conflict" ? `
5827
- QA passed it before, but since then the branch was updated with ${repo.defaultBranch} to resolve merge conflicts. Re-check everything, especially where this change meets the newly merged work.` : rec.fixReason === "checks" ? "\nQA passed it before, but since then the developer changed the code to fix failing GitHub checks. Re-check everything." : rec.round > 1 && rec.summary ? `
5828
- This is a re-test after fixes. Last round's findings:
5829
- ${rec.summary}
5830
- ${rec.fixInstructions ?? ""}
5831
- Check those first, then re-check everything else.` : "",
5956
+ qa.checks,
5957
+ qa.retest,
5832
5958
  "",
5833
5959
  "PR description:",
5834
5960
  pr.body.trim() || "(empty)",
@@ -5836,7 +5962,7 @@ Check those first, then re-check everything else.` : "",
5836
5962
  Linked issue: ${issue2.title}
5837
5963
  ${issue2.body.trim() || "(no description)"}` : ""
5838
5964
  ].filter((l) => l !== "").join("\n");
5839
- this.startAgentSession(a, repo, cwd, prompt, this.buildQaSystemAppend(a, repo, cwd, branch, pr), void 0, QA_SCHEMA);
5965
+ this.startAgentSession(a, repo, cwd, prompt, this.buildQaSystemAppend(a, repo, cwd, branch, pr, qa.testStep), void 0, QA_SCHEMA);
5840
5966
  }
5841
5967
  async onQaFinished(a, repo, result) {
5842
5968
  const rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === a.prNumber);
@@ -5860,22 +5986,25 @@ ${issue2.body.trim() || "(no description)"}` : ""
5860
5986
  this.appendLog(a, [{ kind: pass ? "done" : "error", text: `${pass ? "\u2705 QA passed" : "\u274C QA failed"} PR #${a.prNumber} \xB7 ${report.checks.length} checks \xB7 ${rt.shots.length} screenshots` }]);
5861
5987
  let commentUrl = null;
5862
5988
  if (rec) {
5989
+ const pull = !pass && lastQaRound(rec) ? await this.backend.prDetails(repo.fullName, rec.prNumber).catch(() => null) : null;
5990
+ const next = qaOutcome(pass, rec, pull);
5863
5991
  try {
5864
5992
  this.appendLog(a, [{ kind: "system", text: "\u{1F4CE} Uploading evidence and posting the QA report on the PR\u2026" }]);
5865
- const body = await this.renderQaComment(a, repo, rec, report, rt.shots);
5993
+ const body = await this.renderQaComment(a, repo, rec, report, rt.shots, next);
5866
5994
  commentUrl = await this.backend.commentPull(repo.fullName, rec.prNumber, body) || null;
5867
5995
  this.appendLog(a, [{ kind: "system", text: ` \u23BF ${commentUrl ?? "comment posted"}` }]);
5868
5996
  } catch (err) {
5869
5997
  this.appendLog(a, [{ kind: "error", text: ` \u23BF Could not post the QA report: ${err.message}` }]);
5870
5998
  }
5871
5999
  const qaRounds = rec.round - rec.retests;
5872
- const nextStatus = pass ? "passed" : qaRounds >= MAX_QA_ROUNDS ? "needs-human" : "failed";
6000
+ const nextStatus = next === "conflict" ? "failed" : next;
6001
+ const fixInstructions = report.fixInstructions ?? report.checks.filter((c) => c.result === "fail").map((c) => `${c.name}: ${c.details}`).join("\n");
5873
6002
  this.setQa(rec, {
5874
6003
  status: nextStatus,
5875
6004
  summary: report.summary,
5876
6005
  checks: report.checks,
5877
6006
  commentUrl: commentUrl ?? rec.commentUrl,
5878
- fixInstructions: report.fixInstructions ?? report.checks.filter((c) => c.result === "fail").map((c) => `${c.name}: ${c.details}`).join("\n"),
6007
+ fixInstructions,
5879
6008
  sessionFailures: 0,
5880
6009
  fixReason: pass ? null : "qa",
5881
6010
  passedSha: pass ? rec.testedSha : null,
@@ -5884,13 +6013,14 @@ ${issue2.body.trim() || "(no description)"}` : ""
5884
6013
  mergeRetryAt: null,
5885
6014
  alerted: false
5886
6015
  });
6016
+ if (next === "conflict") this.sendBack(repo, rec, "conflict", conflictFixInstructions(fixInstructions, repo.defaultBranch), false);
5887
6017
  this.toast(
5888
6018
  pass ? "success" : "error",
5889
- pass ? `\u2705 ${a.name} passed PR #${rec.prNumber}${repo.autoMerge ? "; it merges once GitHub's checks are green" : ": ready to merge"}` : nextStatus === "needs-human" ? `\u274C PR #${rec.prNumber} failed QA ${qaRounds} times and needs a human` : `\u274C ${a.name} failed PR #${rec.prNumber}; sending it back to the developer`
6019
+ pass ? `\u2705 ${a.name} passed PR #${rec.prNumber}${repo.autoMerge ? "; it merges once GitHub's checks are green" : ": ready to merge"}` : nextStatus === "needs-human" ? `\u274C PR #${rec.prNumber} failed QA ${qaRounds} times and needs a human` : next === "conflict" ? `\u274C ${a.name} failed PR #${rec.prNumber}, and it conflicts with ${repo.defaultBranch}; sending it back to merge and fix` : `\u274C ${a.name} failed PR #${rec.prNumber}; sending it back to the developer`
5890
6020
  );
5891
6021
  }
5892
6022
  }
5893
- async renderQaComment(a, repo, rec, report, shots) {
6023
+ async renderQaComment(a, repo, rec, report, shots, next) {
5894
6024
  const pass = report.verdict === "pass";
5895
6025
  const images = [];
5896
6026
  const evidence = shots.slice(-8);
@@ -5928,7 +6058,7 @@ ${issue2.body.trim() || "(no description)"}` : ""
5928
6058
  if (images.length) lines.push("", "### \u{1F4F8} Evidence", "", ...images.flatMap((img) => [img, ""]));
5929
6059
  else lines.push("", "_No browser screenshots were taken in this round._");
5930
6060
  const merge = repo.autoMerge ? "merges automatically once GitHub's checks pass" : "ready for the manager to merge";
5931
- lines.push("", `<sub>Posted by cubefarm \xB7 ${pass ? merge : rec.round - rec.retests >= MAX_QA_ROUNDS ? "needs a human decision" : "sent back to the developer for fixes"}</sub>`);
6061
+ lines.push("", `<sub>Posted by cubefarm \xB7 ${pass ? merge : next === "needs-human" ? "needs a human decision" : next === "conflict" ? `sent back to the developer to merge ${repo.defaultBranch} and fix` : "sent back to the developer for fixes"}</sub>`);
5932
6062
  return lines.join("\n");
5933
6063
  }
5934
6064
  /** Send a failed PR back to the developer who wrote it (or any free developer on the floor). */
@@ -5953,7 +6083,7 @@ ${issue2.body.trim() || "(no description)"}` : ""
5953
6083
  const failed2 = rec.checks.filter((c) => c.result === "fail");
5954
6084
  const takeover = original ? "" : " A teammate wrote it, so read the PR and the linked issue first.";
5955
6085
  const push = `push to the same branch: git push origin HEAD:${headRef}`;
5956
- const mergeFix = rec.fixReason === "conflict" ? [
6086
+ const mergeFix = rec.fixReason === "conflict" && rec.passedSha ? [
5957
6087
  `QA passed pull request #${rec.prNumber} (${pull?.url ?? ""}), but it now conflicts with ${repo.defaultBranch} because other work was merged first.${takeover}`,
5958
6088
  "",
5959
6089
  `Bring it up to date: git fetch origin && git merge origin/${repo.defaultBranch}. Resolve the conflicts so both this change and the newly merged work keep working, run the project's checks, and ${push}`