cubefarm 0.3.7 → 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/assets/ToyWorld-CCa77hRS.js +2 -0
- package/dist/assets/{index-hi9vqi9h.css → index-CIr98Zaa.css} +1 -1
- package/dist/assets/{index-D3lYbosx.js → index-g6QWWXij.js} +30 -30
- package/dist/assets/{rapier-DfUh5clU.js → rapier--PAOxOZs.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/index.js +536 -124
- package/dist-server/index.js.map +4 -4
- package/package.json +1 -1
- package/dist/assets/ToyWorld-DQTMvcEl.js +0 -2
package/dist-server/index.js
CHANGED
|
@@ -17,6 +17,7 @@ var DEMO = process.argv.includes("--demo") || process.env.SWARM_DEMO === "1" ||
|
|
|
17
17
|
var STATE_FILE = path.join(HOME_DIR, DEMO ? "demo-state.json" : "state.json");
|
|
18
18
|
var SYNC_INTERVAL_MS = 45e3;
|
|
19
19
|
var SCHEDULER_INTERVAL_MS = 8e3;
|
|
20
|
+
var DESK_SWEEP_INTERVAL_MS = 30 * 6e4;
|
|
20
21
|
var LOG_BUFFER = 600;
|
|
21
22
|
function defaultProjectsDir(appDir, home = os.homedir()) {
|
|
22
23
|
if (!appDir.split(/[\\/]/).includes("node_modules")) return path.resolve(appDir, "..");
|
|
@@ -162,6 +163,9 @@ async function issueState(fullName, number) {
|
|
|
162
163
|
return null;
|
|
163
164
|
}
|
|
164
165
|
}
|
|
166
|
+
async function closeIssue(fullName, number, comment) {
|
|
167
|
+
await gh(["issue", "close", String(number), "-R", fullName, "--reason", "completed", "--comment", comment]);
|
|
168
|
+
}
|
|
165
169
|
async function editIssue(fullName, number, edit) {
|
|
166
170
|
const args = ["issue", "edit", String(number), "-R", fullName];
|
|
167
171
|
if (edit.body !== void 0) args.push("--body-file", "-");
|
|
@@ -190,8 +194,10 @@ async function prForBranch(fullName, branch) {
|
|
|
190
194
|
const list = await ghJson(["pr", "list", "-R", fullName, "--head", branch, "--state", "all", "--json", "number,url", "--limit", "1"]);
|
|
191
195
|
return list[0] ?? null;
|
|
192
196
|
}
|
|
197
|
+
var uniqueNames = (checks) => [...new Set(checks.map(checkName))];
|
|
193
198
|
async function prDetails(fullName, number) {
|
|
194
|
-
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 ?? [];
|
|
195
201
|
return {
|
|
196
202
|
number: raw.number,
|
|
197
203
|
title: raw.title,
|
|
@@ -203,7 +209,11 @@ async function prDetails(fullName, number) {
|
|
|
203
209
|
closesIssues: (raw.closingIssuesReferences ?? []).map((r) => r.number),
|
|
204
210
|
state: raw.state,
|
|
205
211
|
mergeable: raw.mergeable,
|
|
206
|
-
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))
|
|
207
217
|
};
|
|
208
218
|
}
|
|
209
219
|
async function issueDetails(fullName, number) {
|
|
@@ -296,6 +306,33 @@ function npmInstall(dir) {
|
|
|
296
306
|
installs.set(dir, next);
|
|
297
307
|
return next;
|
|
298
308
|
}
|
|
309
|
+
function porcelainPaths(porcelain) {
|
|
310
|
+
return porcelain.split(/\r?\n/).map((line) => /^([ MTADRCU?!]{1,2}) (.+)$/.exec(line)).filter((m) => m !== null).map(([, xy, file]) => /[RC]/.test(xy) ? /^(?:"(?:[^"\\]|\\.)*"|.*?) -> (.+)$/.exec(file)?.[1] ?? file : file);
|
|
311
|
+
}
|
|
312
|
+
function overwrittenPaths(stderr) {
|
|
313
|
+
const lines = stderr.split(/\r?\n/);
|
|
314
|
+
const start = lines.findIndex((l) => /would be overwritten by/.test(l));
|
|
315
|
+
if (start < 0) return [];
|
|
316
|
+
const files = [];
|
|
317
|
+
for (const line of lines.slice(start + 1)) {
|
|
318
|
+
if (!line.startsWith(" ")) break;
|
|
319
|
+
files.push(line.trim());
|
|
320
|
+
}
|
|
321
|
+
return files.filter(Boolean);
|
|
322
|
+
}
|
|
323
|
+
function fileList(files, max = 3, maxChars = 120) {
|
|
324
|
+
const shown = [];
|
|
325
|
+
let length = 0;
|
|
326
|
+
for (const file of files.slice(0, max)) {
|
|
327
|
+
const name = file.length > maxChars ? `\u2026${file.slice(-(maxChars - 1))}` : file;
|
|
328
|
+
const add = name.length + (shown.length ? 2 : 0);
|
|
329
|
+
if (shown.length && length + add > maxChars) break;
|
|
330
|
+
shown.push(name);
|
|
331
|
+
length += add;
|
|
332
|
+
}
|
|
333
|
+
const more = files.length - shown.length;
|
|
334
|
+
return `${shown.join(", ")}${more ? ` +${more} more` : ""}`;
|
|
335
|
+
}
|
|
299
336
|
async function syncMain(fullName, defaultBranch, opts) {
|
|
300
337
|
const dir = mainDir(fullName);
|
|
301
338
|
const result = await withRepoLock(fullName, async () => {
|
|
@@ -313,12 +350,17 @@ async function syncMain(fullName, defaultBranch, opts) {
|
|
|
313
350
|
if (behind === 0) return stays("in sync");
|
|
314
351
|
if (Number(await g(["rev-list", "--count", `${target}..HEAD`])) > 0) return stays(`diverged: local commits, and ${commits} to pull`);
|
|
315
352
|
if (!opts.touch) return { status: `update ready (${commits})`, behind, updatable: true };
|
|
316
|
-
|
|
353
|
+
const dirty = await g(["status", "--porcelain", "--untracked-files=no"]);
|
|
354
|
+
if (dirty) {
|
|
355
|
+
const files = porcelainPaths(dirty);
|
|
356
|
+
return { status: `${behind} behind: local changes${files.length ? ` in ${fileList(files)}` : ""}`, behind, updatable: true };
|
|
357
|
+
}
|
|
317
358
|
const before = await g(["rev-parse", "HEAD"]);
|
|
318
359
|
try {
|
|
319
360
|
await g(["merge", "--ff-only", target], 12e4);
|
|
320
|
-
} catch {
|
|
321
|
-
|
|
361
|
+
} catch (err) {
|
|
362
|
+
const files = overwrittenPaths(err.stderr ?? "");
|
|
363
|
+
return { status: `${behind} behind: local files are in the way${files.length ? `: ${fileList(files)}` : ""}`, behind, updatable: true };
|
|
322
364
|
}
|
|
323
365
|
const changed = (await g(["diff", "--name-only", before, "HEAD"])).split(/\r?\n/);
|
|
324
366
|
const install = changed.some((f) => f === "package.json" || f === "package-lock.json") && await exists(path2.join(dir, "package.json"));
|
|
@@ -421,7 +463,32 @@ async function createProject(root, name, opts) {
|
|
|
421
463
|
async function removeDir(dir) {
|
|
422
464
|
await fs2.rm(dir, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 });
|
|
423
465
|
}
|
|
424
|
-
|
|
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) {
|
|
425
492
|
return withRepoLock(fullName, async () => {
|
|
426
493
|
const main = mainDir(fullName);
|
|
427
494
|
await git(["fetch", "origin", "--prune"], { cwd: main, timeoutMs: 18e4 });
|
|
@@ -440,22 +507,25 @@ function prepareDesk(fullName, base, agentSlug, branch) {
|
|
|
440
507
|
const wt = deskDir(fullName, agentSlug);
|
|
441
508
|
if (await exists(path2.join(wt, ".git"))) {
|
|
442
509
|
try {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
}
|
|
446
519
|
return wt;
|
|
447
|
-
} 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);
|
|
448
524
|
}
|
|
449
525
|
}
|
|
450
526
|
if (await exists(wt)) {
|
|
451
527
|
await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
|
|
452
|
-
|
|
453
|
-
await removeDir(wt);
|
|
454
|
-
} catch (err) {
|
|
455
|
-
throw new Error(
|
|
456
|
-
`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.`
|
|
457
|
-
);
|
|
458
|
-
}
|
|
528
|
+
await clearDeskFolder(wt);
|
|
459
529
|
}
|
|
460
530
|
await git(["worktree", "prune"], { cwd: main });
|
|
461
531
|
await fs2.mkdir(path2.dirname(wt), { recursive: true });
|
|
@@ -463,9 +533,8 @@ function prepareDesk(fullName, base, agentSlug, branch) {
|
|
|
463
533
|
return wt;
|
|
464
534
|
});
|
|
465
535
|
}
|
|
466
|
-
function removeDesk(fullName, agentSlug) {
|
|
536
|
+
function removeDesk(fullName, agentSlug, main = mainDir(fullName)) {
|
|
467
537
|
return withRepoLock(fullName, async () => {
|
|
468
|
-
const main = mainDir(fullName);
|
|
469
538
|
const wt = deskDir(fullName, agentSlug);
|
|
470
539
|
if (!await exists(wt)) return;
|
|
471
540
|
await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
|
|
@@ -473,6 +542,121 @@ function removeDesk(fullName, agentSlug) {
|
|
|
473
542
|
await git(["worktree", "prune"], { cwd: main }).catch(() => void 0);
|
|
474
543
|
});
|
|
475
544
|
}
|
|
545
|
+
var OLD_HOMES = [".office-swarm"];
|
|
546
|
+
var OFFICE_BRANCH = /^(swarm\/issue-|qa\/pr-)/;
|
|
547
|
+
function parseWorktrees(porcelain) {
|
|
548
|
+
const out = [];
|
|
549
|
+
for (const line of porcelain.split(/\r?\n/)) {
|
|
550
|
+
if (line.startsWith("worktree ")) out.push({ path: path2.resolve(line.slice(9)), branch: null, locked: false });
|
|
551
|
+
else if (!out.length) continue;
|
|
552
|
+
else if (line.startsWith("branch ")) out[out.length - 1].branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
553
|
+
else if (line === "locked" || line.startsWith("locked ")) out[out.length - 1].locked = true;
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
var samePath = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
558
|
+
function isOfficeDesk(p, fullName, root) {
|
|
559
|
+
const desks = path2.dirname(p);
|
|
560
|
+
const repo = path2.dirname(desks);
|
|
561
|
+
const workspaces = path2.dirname(repo);
|
|
562
|
+
if (!samePath(path2.basename(desks), "desks") || !samePath(path2.basename(repo), fullName.replace("/", "__"))) return false;
|
|
563
|
+
if (samePath(workspaces, path2.resolve(root))) return true;
|
|
564
|
+
return path2.basename(workspaces) === "workspaces" && OLD_HOMES.some((h) => samePath(path2.basename(path2.dirname(workspaces)), h));
|
|
565
|
+
}
|
|
566
|
+
function planSweep(input, keep) {
|
|
567
|
+
const deskRoot = path2.join(path2.resolve(input.root), input.fullName.replace("/", "__"), "desks");
|
|
568
|
+
const kept = (p) => keep.desks.some((slug) => samePath(p, path2.join(deskRoot, slug)));
|
|
569
|
+
const worktrees = input.worktrees.slice(1).filter((w) => !w.locked && isOfficeDesk(w.path, input.fullName, input.root) && !kept(w.path));
|
|
570
|
+
const stays = input.worktrees.filter((w) => !worktrees.includes(w));
|
|
571
|
+
const folders = input.folders.filter((name) => {
|
|
572
|
+
const p = path2.join(deskRoot, name);
|
|
573
|
+
return !kept(p) && !input.worktrees.some((w) => samePath(w.path, p));
|
|
574
|
+
});
|
|
575
|
+
const branches = input.branches.filter((b) => OFFICE_BRANCH.test(b) && !keep.branches.includes(b) && !stays.some((w) => w.branch === b));
|
|
576
|
+
return { worktrees, folders, branches };
|
|
577
|
+
}
|
|
578
|
+
async function inUse(dir) {
|
|
579
|
+
const probe = `${dir}.sweep`;
|
|
580
|
+
try {
|
|
581
|
+
await fs2.rename(dir, probe);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
return err.code !== "ENOENT";
|
|
584
|
+
}
|
|
585
|
+
await fs2.rename(probe, dir);
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
var stamp = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
|
589
|
+
async function saveLeftovers(fullName, desk) {
|
|
590
|
+
const g = (args) => git(args, { cwd: desk, timeoutMs: 6e4 });
|
|
591
|
+
if (!await g(["rev-parse", "--verify", "-q", "HEAD"]).catch(() => "")) return null;
|
|
592
|
+
const dirty = await g(["status", "--porcelain", "--untracked-files=no"]);
|
|
593
|
+
const commits = Number(await g(["rev-list", "--count", "HEAD", "--not", "--remotes"]));
|
|
594
|
+
if (!dirty && !commits) return null;
|
|
595
|
+
const dir = path2.join(HOME_DIR, "leftovers", fullName.replace("/", "__"));
|
|
596
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
597
|
+
const tmp = await fs2.mkdtemp(path2.join(dir, ".tmp-"));
|
|
598
|
+
try {
|
|
599
|
+
if (commits) await g(["format-patch", "-q", "-o", tmp, "HEAD", "--not", "--remotes"]);
|
|
600
|
+
if (dirty) await g(["diff", "HEAD", "--binary", `--output=${path2.join(tmp, "zz-uncommitted.patch")}`]);
|
|
601
|
+
const parts = (await fs2.readdir(tmp)).sort();
|
|
602
|
+
const base = `${path2.basename(desk)}-${stamp(/* @__PURE__ */ new Date())}`;
|
|
603
|
+
let file = path2.join(dir, `${base}.patch`);
|
|
604
|
+
for (let n = 2; await exists(file); n++) file = path2.join(dir, `${base}-${n}.patch`);
|
|
605
|
+
await fs2.writeFile(file, Buffer.concat(await Promise.all(parts.map((p) => fs2.readFile(path2.join(tmp, p))))));
|
|
606
|
+
return file;
|
|
607
|
+
} finally {
|
|
608
|
+
await removeDir(tmp).catch(() => void 0);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function sweepDesks(fullName, keep) {
|
|
612
|
+
return withRepoLock(fullName, async () => {
|
|
613
|
+
const result = { desks: 0, folders: 0, branches: 0, patches: [], skipped: [] };
|
|
614
|
+
const main = mainDir(fullName);
|
|
615
|
+
if (!await exists(path2.join(main, ".git"))) return result;
|
|
616
|
+
const g = (args) => git(args, { cwd: main, timeoutMs: 6e4 });
|
|
617
|
+
await g(["worktree", "prune"]);
|
|
618
|
+
const real = (p) => fs2.realpath(p).catch(() => p);
|
|
619
|
+
const worktrees = await Promise.all(parseWorktrees(await g(["worktree", "list", "--porcelain"])).map(async (w) => ({ ...w, path: await real(w.path) })));
|
|
620
|
+
const refs = await g(["for-each-ref", "--format=%(refname)", "refs/heads/swarm", "refs/heads/qa"]);
|
|
621
|
+
const branches = refs.split(/\r?\n/).filter(Boolean).map((r) => r.replace(/^refs\/heads\//, ""));
|
|
622
|
+
const root = await real(WORKSPACE_ROOT);
|
|
623
|
+
const deskRoot = path2.join(root, fullName.replace("/", "__"), "desks");
|
|
624
|
+
const folders = (await fs2.readdir(deskRoot, { withFileTypes: true }).catch(() => [])).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
625
|
+
const plan = planSweep({ fullName, root, worktrees, branches, folders }, keep);
|
|
626
|
+
const held = /* @__PURE__ */ new Set();
|
|
627
|
+
for (const wt of plan.worktrees) {
|
|
628
|
+
try {
|
|
629
|
+
if (await inUse(wt.path)) throw new Error("in use");
|
|
630
|
+
const patch = await saveLeftovers(fullName, wt.path);
|
|
631
|
+
if (patch) result.patches.push(patch);
|
|
632
|
+
await g(["worktree", "remove", "--force", wt.path]).catch(() => void 0);
|
|
633
|
+
await removeDir(wt.path);
|
|
634
|
+
result.desks++;
|
|
635
|
+
} catch {
|
|
636
|
+
result.skipped.push(wt.path);
|
|
637
|
+
if (wt.branch) held.add(wt.branch);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
await g(["worktree", "prune"]).catch(() => void 0);
|
|
641
|
+
for (const name of plan.folders) {
|
|
642
|
+
const dir = path2.join(deskRoot, name);
|
|
643
|
+
try {
|
|
644
|
+
if (await inUse(dir)) throw new Error("in use");
|
|
645
|
+
await removeDir(dir);
|
|
646
|
+
result.folders++;
|
|
647
|
+
} catch {
|
|
648
|
+
result.skipped.push(dir);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const doomed = plan.branches.filter((b) => !held.has(b));
|
|
652
|
+
for (let i = 0; i < doomed.length; i += 40) await g(["branch", "-D", ...doomed.slice(i, i + 40)]).catch(() => void 0);
|
|
653
|
+
if (doomed.length) {
|
|
654
|
+
const left = new Set((await g(["for-each-ref", "--format=%(refname)", "refs/heads/swarm", "refs/heads/qa"])).split(/\r?\n/));
|
|
655
|
+
result.branches = doomed.filter((b) => !left.has(`refs/heads/${b}`)).length;
|
|
656
|
+
}
|
|
657
|
+
return result;
|
|
658
|
+
});
|
|
659
|
+
}
|
|
476
660
|
var LAUNCHERS = ["node.exe", "cmd.exe", "bash.exe", "sh.exe", "conhost.exe", "python.exe", "npm.exe", "npx.exe", "bun.exe", "deno.exe"];
|
|
477
661
|
var pathForms = (p) => [.../* @__PURE__ */ new Set([p, p.replaceAll("\\", "/"), p.replaceAll("\\", "\\\\")])];
|
|
478
662
|
function leftoversInDesk(listing, desk, keep) {
|
|
@@ -1552,12 +1736,13 @@ function endLive(live) {
|
|
|
1552
1736
|
routes.delete(live.token);
|
|
1553
1737
|
live.term.releaseIdle = null;
|
|
1554
1738
|
live.term.bind(null);
|
|
1555
|
-
const gone = live.proc ? exitOf(live.proc) :
|
|
1739
|
+
const gone = live.proc ? exitOf(live.proc) : Promise.resolve();
|
|
1556
1740
|
live.proc?.kill();
|
|
1557
1741
|
live.proc = null;
|
|
1558
1742
|
live.term.note(`\u2500\u2500 ${cliLabel(live.cli)} session ended \u2500\u2500`);
|
|
1559
1743
|
fs7.rm(live.dir, { recursive: true, force: true, maxRetries: 5 }, () => void 0);
|
|
1560
|
-
|
|
1744
|
+
if (live.cli === "codex" && live.resumeId) void codexThread("archive", live.resumeId, gone);
|
|
1745
|
+
return gone;
|
|
1561
1746
|
}
|
|
1562
1747
|
var metaOf = (l) => ({ agentId: l.agentId, cli: l.cli, token: l.token, dir: l.dir, resumeId: l.resumeId, busy: !!l.session });
|
|
1563
1748
|
var remember = (l) => l.proc?.setMeta(metaOf(l));
|
|
@@ -2277,6 +2462,7 @@ var realBackend = {
|
|
|
2277
2462
|
createIssue,
|
|
2278
2463
|
issueState,
|
|
2279
2464
|
editIssue,
|
|
2465
|
+
closeIssue,
|
|
2280
2466
|
mergePull,
|
|
2281
2467
|
updateBranch,
|
|
2282
2468
|
closePull,
|
|
@@ -2296,6 +2482,7 @@ var realBackend = {
|
|
|
2296
2482
|
deskDir,
|
|
2297
2483
|
prepareDesk,
|
|
2298
2484
|
removeDesk,
|
|
2485
|
+
sweepDesks,
|
|
2299
2486
|
releaseDesk: (fullName, agentSlug, port) => releaseDesk(fullName, agentSlug, port, officeProcesses()),
|
|
2300
2487
|
startSession: (opts, cb) => opts.terminal ? startCliSession(opts, cb) : startSession(opts, cb),
|
|
2301
2488
|
terminals: terminalsAvailable,
|
|
@@ -2399,18 +2586,25 @@ function devScript(opts, cb, issueNumber, issueTitle) {
|
|
|
2399
2586
|
[{ kind: "tool", tool: "Bash", text: `\u23FA $ gh pr create --title "${issueTitle}" --body "Closes #${issueNumber}"` }]
|
|
2400
2587
|
];
|
|
2401
2588
|
}
|
|
2402
|
-
function qaScript(cb, pr, title, round) {
|
|
2589
|
+
function qaScript(cb, pr, title, round, checks) {
|
|
2403
2590
|
const port = 5600 + pr % 50;
|
|
2404
2591
|
const hue = pr * 41 % 360;
|
|
2405
|
-
|
|
2406
|
-
[{ kind: "text", text: `\u25CF Testing PR #${pr} "${title}" (round ${round}). First, the acceptance criteria from the issue.` }],
|
|
2407
|
-
[{ 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") ? [
|
|
2408
2593
|
[{ kind: "tool", tool: "Bash", text: "\u23FA $ npm ci && npm test -- --run" }],
|
|
2409
2594
|
[
|
|
2410
2595
|
{ kind: "result", text: " \u23BF Test Files 8 passed (8)" },
|
|
2411
2596
|
{ kind: "result", text: " Tests 41 passed (41)" }
|
|
2412
2597
|
],
|
|
2413
|
-
[{ 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,
|
|
2414
2608
|
[{ kind: "tool", tool: "Bash", text: `\u23FA $ npm run preview -- --port ${port} &` }, { kind: "result", text: ` \u23BF Local: http://localhost:${port}/` }],
|
|
2415
2609
|
() => cb.browserUrl(`http://localhost:${port}/`),
|
|
2416
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}/` }],
|
|
@@ -2446,7 +2640,8 @@ function fakeSession(opts, cb, fullName) {
|
|
|
2446
2640
|
{ kind: "system", text: `\u273B Claude Code (demo) \xB7 ${opts.model} \xB7 ${opts.effort} effort` },
|
|
2447
2641
|
{ kind: "system", text: ` cwd ${opts.cwd}` }
|
|
2448
2642
|
];
|
|
2449
|
-
const
|
|
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);
|
|
2450
2645
|
const script = [header, ...body];
|
|
2451
2646
|
const finish = () => {
|
|
2452
2647
|
const costUsd = 0.3 + Math.random();
|
|
@@ -2697,8 +2892,16 @@ function createDemoBackend() {
|
|
|
2697
2892
|
mergedSinceSync.set(fullName, (mergedSinceSync.get(fullName) ?? 0) + 1);
|
|
2698
2893
|
pr.state = "MERGED";
|
|
2699
2894
|
pr.mergedAt = now();
|
|
2700
|
-
|
|
2701
|
-
|
|
2895
|
+
setTimeout(() => {
|
|
2896
|
+
for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
|
|
2897
|
+
r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
|
|
2898
|
+
}, 2e4).unref();
|
|
2899
|
+
},
|
|
2900
|
+
closeIssue: async (fullName, number) => {
|
|
2901
|
+
const r = repos.get(fullName);
|
|
2902
|
+
if (!r?.issues.some((i) => i.number === number)) throw new Error(`Issue #${number} is not open`);
|
|
2903
|
+
closedIssues.add(`${fullName}#${number}`);
|
|
2904
|
+
r.issues = r.issues.filter((i) => i.number !== number);
|
|
2702
2905
|
},
|
|
2703
2906
|
updateBranch: async (fullName, number) => {
|
|
2704
2907
|
const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
|
|
@@ -2727,7 +2930,11 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
|
|
|
2727
2930
|
closesIssues: pr.closesIssues,
|
|
2728
2931
|
state: pr.state,
|
|
2729
2932
|
mergeable: pr.mergeable,
|
|
2730
|
-
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
|
|
2731
2938
|
};
|
|
2732
2939
|
},
|
|
2733
2940
|
issueDetails: async (fullName, number) => {
|
|
@@ -2753,6 +2960,7 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
|
|
|
2753
2960
|
return dir;
|
|
2754
2961
|
},
|
|
2755
2962
|
removeDesk: async () => void 0,
|
|
2963
|
+
sweepDesks: async () => ({ desks: 0, folders: 0, branches: 0, patches: [], skipped: [] }),
|
|
2756
2964
|
releaseDesk: async () => void 0,
|
|
2757
2965
|
startSession: (opts, cb) => {
|
|
2758
2966
|
if (++sessionsStarted === USAGE_WARNING_AT) fakeUsageWarning(cb);
|
|
@@ -3488,6 +3696,13 @@ function specialtySlug(s) {
|
|
|
3488
3696
|
}
|
|
3489
3697
|
var specialtyLabel = (slug) => `swarm:${slug}`;
|
|
3490
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
|
+
|
|
3491
3706
|
// server/fixOutcome.ts
|
|
3492
3707
|
var MAX_FIX_FAILURES = 2;
|
|
3493
3708
|
function fixOutcome(rec, head, minutes, counts = true) {
|
|
@@ -3502,7 +3717,7 @@ function fixOutcome(rec, head, minutes, counts = true) {
|
|
|
3502
3717
|
pushed: false
|
|
3503
3718
|
};
|
|
3504
3719
|
}
|
|
3505
|
-
if (rec.fixReason === "checks" || rec.fixReason === "conflict") {
|
|
3720
|
+
if (rec.fixReason === "checks" || rec.fixReason === "conflict" && rec.passedSha) {
|
|
3506
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 };
|
|
3507
3722
|
}
|
|
3508
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 };
|
|
@@ -3517,6 +3732,25 @@ var HttpError = class extends Error {
|
|
|
3517
3732
|
status;
|
|
3518
3733
|
};
|
|
3519
3734
|
|
|
3735
|
+
// server/issueClaims.ts
|
|
3736
|
+
var RECENT_MERGE_MS = 6 * 60 * 6e4;
|
|
3737
|
+
function branchIssue(headRefName) {
|
|
3738
|
+
const m = /^swarm\/issue-(\d+)-/.exec(headRefName);
|
|
3739
|
+
return m ? Number(m[1]) : null;
|
|
3740
|
+
}
|
|
3741
|
+
function issuesResolvedBy(pr) {
|
|
3742
|
+
const n = branchIssue(pr.headRefName);
|
|
3743
|
+
return [...new Set(n == null ? pr.closesIssues : [...pr.closesIssues, n])];
|
|
3744
|
+
}
|
|
3745
|
+
function issueTaken(n, agents, pulls, now2) {
|
|
3746
|
+
if (agents.some((a) => a.role === "dev" && a.task !== "qa" && a.issueNumber === n && a.status !== "idle" && a.status !== "error")) return true;
|
|
3747
|
+
return pulls.some((p) => {
|
|
3748
|
+
if (p.state === "CLOSED") return false;
|
|
3749
|
+
if (p.state === "MERGED" && (!p.mergedAt || now2 - Date.parse(p.mergedAt) > RECENT_MERGE_MS)) return false;
|
|
3750
|
+
return issuesResolvedBy(p).includes(n);
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3520
3754
|
// server/mergeGate.ts
|
|
3521
3755
|
var MAX_MERGE_FIXES = 3;
|
|
3522
3756
|
var CHECKS_ALERT_MS = 30 * 6e4;
|
|
@@ -3556,6 +3790,57 @@ function orphanedQa(qa, agents, busy) {
|
|
|
3556
3790
|
);
|
|
3557
3791
|
}
|
|
3558
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
|
+
|
|
3559
3844
|
// server/restartRecovery.ts
|
|
3560
3845
|
function followKeptCli(a, cli) {
|
|
3561
3846
|
if (!cli?.busy || a.status === "preparing") return false;
|
|
@@ -3724,6 +4009,7 @@ var Previews = class {
|
|
|
3724
4009
|
}
|
|
3725
4010
|
/** The floor is leaving the building: stop its preview and remove the preview worktree. */
|
|
3726
4011
|
async remove(f) {
|
|
4012
|
+
const main = this.backend.mainDir(f.fullName);
|
|
3727
4013
|
const r = this.runs.get(f.id);
|
|
3728
4014
|
if (r) {
|
|
3729
4015
|
r.gen++;
|
|
@@ -3732,7 +4018,7 @@ var Previews = class {
|
|
|
3732
4018
|
this.runs.delete(f.id);
|
|
3733
4019
|
this.hasDefault.delete(f.id);
|
|
3734
4020
|
clearTimeout(this.emitTimers.get(f.id));
|
|
3735
|
-
await this.backend.removeDesk(f.fullName, PREVIEW_SLUG).catch(() => void 0);
|
|
4021
|
+
await this.backend.removeDesk(f.fullName, PREVIEW_SLUG, main).catch(() => void 0);
|
|
3736
4022
|
}
|
|
3737
4023
|
/** Server shutdown: stop every running preview. */
|
|
3738
4024
|
async stopAll(floors) {
|
|
@@ -3777,6 +4063,96 @@ var Previews = class {
|
|
|
3777
4063
|
}
|
|
3778
4064
|
};
|
|
3779
4065
|
|
|
4066
|
+
// server/prompts.ts
|
|
4067
|
+
var PLACEHOLDERS = {
|
|
4068
|
+
worktree: "<your worktree>",
|
|
4069
|
+
issue: "<issue>",
|
|
4070
|
+
pr: "<PR>",
|
|
4071
|
+
prTitle: "<PR title>",
|
|
4072
|
+
prUrl: "<PR URL>",
|
|
4073
|
+
branch: "<branch>"
|
|
4074
|
+
};
|
|
4075
|
+
var devBranch = (issue2, slug) => `swarm/issue-${issue2}-${slug}`;
|
|
4076
|
+
var qaBranch = (pr, slug) => `qa/pr-${pr}-${slug}`;
|
|
4077
|
+
function devSystemPrompt({ agent: a, repo, port, cwd, branch, linked, fixing }) {
|
|
4078
|
+
const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
|
|
4079
|
+
const links = linked.map((r) => `- ${r.fullName}: read-only reference clone at ${r.dir}`);
|
|
4080
|
+
return [
|
|
4081
|
+
`You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
|
|
4082
|
+
`Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
|
|
4083
|
+
a.brief ? `
|
|
4084
|
+
Your job description:
|
|
4085
|
+
${a.brief}` : "",
|
|
4086
|
+
"",
|
|
4087
|
+
`Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
|
|
4088
|
+
repo.summary ? `Project: ${repo.summary}` : "",
|
|
4089
|
+
repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
|
|
4090
|
+
`Your worktree: ${cwd}`,
|
|
4091
|
+
fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
|
|
4092
|
+
links.length ? `Related repositories you may read for context (do not modify them):
|
|
4093
|
+
${links.join("\n")}` : "",
|
|
4094
|
+
"",
|
|
4095
|
+
"Workflow:",
|
|
4096
|
+
"1. Read the issue and explore the relevant code before changing anything.",
|
|
4097
|
+
"2. Implement the change with focused commits and clear messages.",
|
|
4098
|
+
"3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
|
|
4099
|
+
repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${port} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
|
|
4100
|
+
`5. Push: ${push}`,
|
|
4101
|
+
fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
|
|
4102
|
+
fixing ? "" : "7. End your final message with the pull request URL on its own line.",
|
|
4103
|
+
"",
|
|
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."
|
|
4105
|
+
].filter((l) => l !== "").join("\n");
|
|
4106
|
+
}
|
|
4107
|
+
function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr, testStep: testStep2 }) {
|
|
4108
|
+
return [
|
|
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.`,
|
|
4110
|
+
...a.brief ? ["", `Your job description:
|
|
4111
|
+
${a.brief}`] : [],
|
|
4112
|
+
...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
|
|
4113
|
+
"",
|
|
4114
|
+
`Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
|
|
4115
|
+
...repo.summary ? [`Project: ${repo.summary}`] : [],
|
|
4116
|
+
...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
|
|
4117
|
+
...repo.qaBrief ? [`What to check on this project (from the CEO):
|
|
4118
|
+
${repo.qaBrief}`] : [],
|
|
4119
|
+
`Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
|
|
4120
|
+
`Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
|
|
4121
|
+
"",
|
|
4122
|
+
"How to test:",
|
|
4123
|
+
"1. Read the PR description and the linked issue, and work out the acceptance criteria.",
|
|
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.`,
|
|
4125
|
+
testStep2 ?? "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
|
|
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).",
|
|
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.",
|
|
4129
|
+
"",
|
|
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."
|
|
4131
|
+
].join("\n");
|
|
4132
|
+
}
|
|
4133
|
+
var OFFICE = "Office instructions";
|
|
4134
|
+
var MARK = "\0job description\0";
|
|
4135
|
+
function promptParts(build, brief) {
|
|
4136
|
+
if (!brief) return [{ label: OFFICE, text: build(""), editable: false }];
|
|
4137
|
+
const [before, after] = build(MARK).split(MARK);
|
|
4138
|
+
return [
|
|
4139
|
+
{ label: OFFICE, text: before, editable: false },
|
|
4140
|
+
{ label: "Job description", text: brief, editable: true },
|
|
4141
|
+
{ label: OFFICE, text: after, editable: false }
|
|
4142
|
+
].filter((p) => p.text !== "");
|
|
4143
|
+
}
|
|
4144
|
+
var view = (kind, parts) => ({ kind, text: parts.map((p) => p.text).join(""), parts });
|
|
4145
|
+
function devPromptPreview({ slug, ...i }) {
|
|
4146
|
+
const branch = devBranch(PLACEHOLDERS.issue, slug);
|
|
4147
|
+
return view("dev", promptParts((brief) => devSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch }), i.agent.brief));
|
|
4148
|
+
}
|
|
4149
|
+
function qaPromptPreview({ slug, ...i }) {
|
|
4150
|
+
const pr = { number: PLACEHOLDERS.pr, title: PLACEHOLDERS.prTitle, headRefName: PLACEHOLDERS.branch, url: PLACEHOLDERS.prUrl };
|
|
4151
|
+
const branch = qaBranch(PLACEHOLDERS.pr, slug);
|
|
4152
|
+
return view("qa", promptParts((brief) => qaSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch, pr }), i.agent.brief));
|
|
4153
|
+
}
|
|
4154
|
+
var ceoPromptPreview = (text) => view("ceo", [{ label: OFFICE, text, editable: false }]);
|
|
4155
|
+
|
|
3780
4156
|
// server/pacing.ts
|
|
3781
4157
|
var PACING_MS = 60 * 6e4;
|
|
3782
4158
|
var DEFAULT_PACING_SESSIONS = 3;
|
|
@@ -3835,7 +4211,10 @@ var AgentTerminal = class {
|
|
|
3835
4211
|
rows = TERM_ROWS;
|
|
3836
4212
|
/** Output arrived since the last save. */
|
|
3837
4213
|
dirty = false;
|
|
3838
|
-
/**
|
|
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
|
+
*/
|
|
3839
4218
|
releaseIdle = null;
|
|
3840
4219
|
/** Something the manager typed at that waiting prompt: the office takes it on as a follow-up (true) or refuses. */
|
|
3841
4220
|
onIdlePrompt = null;
|
|
@@ -4046,7 +4425,6 @@ var FEMININE_NAMES = new Set(
|
|
|
4046
4425
|
var lookFor = (name) => FEMININE_NAMES.has(name.trim().split(/\s+/)[0].toLowerCase()) ? "feminine" : "masculine";
|
|
4047
4426
|
var LOOKS = ["feminine", "masculine"];
|
|
4048
4427
|
var MAX_DESKS = { ...FLOOR_DESKS, ceo: 1 };
|
|
4049
|
-
var MAX_QA_ROUNDS = 3;
|
|
4050
4428
|
var DEFAULT_MODEL = "claude-opus-5-5";
|
|
4051
4429
|
var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
4052
4430
|
var CEO_MODEL = "claude-opus-5-5";
|
|
@@ -4211,6 +4589,8 @@ var Swarm = class {
|
|
|
4211
4589
|
messageSeq = 1;
|
|
4212
4590
|
agentRt = /* @__PURE__ */ new Map();
|
|
4213
4591
|
repoRt = /* @__PURE__ */ new Map();
|
|
4592
|
+
deskAlerts = /* @__PURE__ */ new Map();
|
|
4593
|
+
// agent id -> the desk setup error the manager was last told about
|
|
4214
4594
|
clients = /* @__PURE__ */ new Set();
|
|
4215
4595
|
user = null;
|
|
4216
4596
|
ghError;
|
|
@@ -4363,6 +4743,7 @@ var Swarm = class {
|
|
|
4363
4743
|
setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
|
|
4364
4744
|
setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
|
|
4365
4745
|
setInterval(() => void this.saveTerminals(), TERMINAL_SAVE_MS);
|
|
4746
|
+
setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.sweepFloor(r.id), i * 1500)), DESK_SWEEP_INTERVAL_MS);
|
|
4366
4747
|
void this.backend.detectClis().then((clis) => {
|
|
4367
4748
|
this.clis = clis;
|
|
4368
4749
|
this.broadcast({ type: "clis", clis });
|
|
@@ -4805,8 +5186,29 @@ var Swarm = class {
|
|
|
4805
5186
|
return this.mergeNote(rec, `merge blocked: ${error}`);
|
|
4806
5187
|
}
|
|
4807
5188
|
this.toast("success", `\u{1F500} Merged PR #${pr.number} into ${repo.defaultBranch}: ${pr.title}`);
|
|
5189
|
+
await this.closeResolvedIssues(repo, pr.number, pr);
|
|
4808
5190
|
return true;
|
|
4809
5191
|
}
|
|
5192
|
+
/**
|
|
5193
|
+
* After the office merges a PR: close the issues it resolves now rather than waiting for GitHub's "Closes #N" (which
|
|
5194
|
+
* lags, and never comes when the link wasn't recognised), so the scheduler doesn't hand them out again. Never throws.
|
|
5195
|
+
*/
|
|
5196
|
+
async closeResolvedIssues(repo, prNumber, pr) {
|
|
5197
|
+
const numbers = issuesResolvedBy(pr);
|
|
5198
|
+
const rt = this.repoRt.get(repo.id);
|
|
5199
|
+
if (rt && rt.issues.some((i) => numbers.includes(i.number))) {
|
|
5200
|
+
rt.issues = rt.issues.filter((i) => !numbers.includes(i.number));
|
|
5201
|
+
this.emitRepo(repo);
|
|
5202
|
+
}
|
|
5203
|
+
for (const n of numbers) {
|
|
5204
|
+
try {
|
|
5205
|
+
if (await this.backend.issueState(repo.fullName, n) !== "OPEN") continue;
|
|
5206
|
+
await this.backend.closeIssue(repo.fullName, n, `Shipped in #${prNumber}`);
|
|
5207
|
+
} catch (err) {
|
|
5208
|
+
console.warn(`could not close ${repo.fullName}#${n} after merging #${prNumber}`, err);
|
|
5209
|
+
}
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
4810
5212
|
/** Show where auto-merge stands on a PR's card. Doesn't count as a change to the record. */
|
|
4811
5213
|
mergeNote(rec, text) {
|
|
4812
5214
|
if (rec.mergeNote !== text) {
|
|
@@ -4849,6 +5251,31 @@ var Swarm = class {
|
|
|
4849
5251
|
async syncFolderNow(repoId2) {
|
|
4850
5252
|
return { folderSync: await this.syncFolder(this.repo(repoId2)) };
|
|
4851
5253
|
}
|
|
5254
|
+
/** Remove the floor's desks, desk folders and swarm/qa branches nobody uses any more; say so when something went. */
|
|
5255
|
+
async sweepFloor(repoId2) {
|
|
5256
|
+
const repo = this.state.repos.find((r) => r.id === repoId2);
|
|
5257
|
+
const rt = this.repoRt.get(repoId2);
|
|
5258
|
+
if (!repo || !rt || rt.cloneStatus !== "ready") return;
|
|
5259
|
+
try {
|
|
5260
|
+
const pulls = rt.lastSync ? rt.pulls : await this.backend.listPulls(repo.fullName);
|
|
5261
|
+
const agents = this.state.agents.filter((a) => a.repoId === repo.id);
|
|
5262
|
+
const keep = {
|
|
5263
|
+
desks: [...agents.map((a) => this.agentSlug(a)), PREVIEW_SLUG],
|
|
5264
|
+
branches: [...agents.flatMap((a) => a.branch ? [a.branch] : []), ...pulls.filter((p) => p.state === "OPEN").map((p) => p.headRefName)]
|
|
5265
|
+
};
|
|
5266
|
+
const r = await this.backend.sweepDesks(repo.fullName, keep);
|
|
5267
|
+
const desks = r.desks + r.folders;
|
|
5268
|
+
const saved = r.patches.length ? ` (${r.patches.length} patch${r.patches.length === 1 ? "" : "es"} saved to ${path13.dirname(r.patches[0])})` : "";
|
|
5269
|
+
console.log(`desk sweep ${repo.fullName}: ${desks} desks, ${r.branches} branches removed${saved}${r.skipped.length ? `, ${r.skipped.length} still in use` : ""}`);
|
|
5270
|
+
if (!desks && !r.branches) return;
|
|
5271
|
+
const removed = [desks && `${desks} old desk${desks === 1 ? "" : "s"}`, r.branches && `${r.branches} finished branch${r.branches === 1 ? "" : "es"}`].filter(Boolean).join(" and ");
|
|
5272
|
+
const text = `\u{1F9F9} ${repo.fullName.split("/")[1]}: removed ${removed}${saved}`;
|
|
5273
|
+
this.toast("info", text);
|
|
5274
|
+
this.postMessage("office", text);
|
|
5275
|
+
} catch (err) {
|
|
5276
|
+
console.warn(`desk sweep ${repo.fullName} failed: ${oneLine(err)}`);
|
|
5277
|
+
}
|
|
5278
|
+
}
|
|
4852
5279
|
// ---------- the floor's app (preview monitor) ----------
|
|
4853
5280
|
/** Run the floor's app from its preview worktree: the default branch, or an open PR. Replaces what it is running now. */
|
|
4854
5281
|
async startPreview(id, pr) {
|
|
@@ -4879,6 +5306,7 @@ var Swarm = class {
|
|
|
4879
5306
|
await this.backend.ensureClone(repo.fullName);
|
|
4880
5307
|
rt.cloneStatus = "ready";
|
|
4881
5308
|
void this.previews.refreshDefault(repo);
|
|
5309
|
+
void this.sweepFloor(id);
|
|
4882
5310
|
} catch (err) {
|
|
4883
5311
|
rt.cloneStatus = "error";
|
|
4884
5312
|
rt.cloneError = err.message;
|
|
@@ -4954,8 +5382,10 @@ var Swarm = class {
|
|
|
4954
5382
|
}
|
|
4955
5383
|
async mergePull(repoId2, number, method = "squash") {
|
|
4956
5384
|
const repo = this.repo(repoId2);
|
|
5385
|
+
const pr = this.repoRt.get(repo.id)?.pulls.find((p) => p.number === number) ?? await this.backend.prDetails(repo.fullName, number);
|
|
4957
5386
|
await this.backend.mergePull(repo.fullName, number, method);
|
|
4958
5387
|
this.toast("success", `Merged PR #${number} into ${repo.defaultBranch}`);
|
|
5388
|
+
await this.closeResolvedIssues(repo, number, pr);
|
|
4959
5389
|
await this.syncRepo(repo.id);
|
|
4960
5390
|
setTimeout(() => this.schedule(), 200);
|
|
4961
5391
|
}
|
|
@@ -5072,7 +5502,8 @@ var Swarm = class {
|
|
|
5072
5502
|
const repo = this.state.repos.find((r) => r.id === a.repoId);
|
|
5073
5503
|
if (repo) {
|
|
5074
5504
|
const slug = this.agentSlug(a);
|
|
5075
|
-
|
|
5505
|
+
const main = this.backend.mainDir(repo.fullName);
|
|
5506
|
+
void this.backend.releaseDesk(repo.fullName, slug, this.port(a)).then(() => this.backend.removeDesk(repo.fullName, slug, main)).catch(() => void 0).then(() => this.sweepFloor(repo.id));
|
|
5076
5507
|
}
|
|
5077
5508
|
this.state.agents = this.state.agents.filter((x) => x.id !== id);
|
|
5078
5509
|
this.agentRt.delete(id);
|
|
@@ -5116,9 +5547,12 @@ var Swarm = class {
|
|
|
5116
5547
|
this.save();
|
|
5117
5548
|
}
|
|
5118
5549
|
issueTaken(repo, n) {
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5550
|
+
return issueTaken(
|
|
5551
|
+
n,
|
|
5552
|
+
this.state.agents.filter((a) => a.repoId === repo.id),
|
|
5553
|
+
this.repoRt.get(repo.id)?.pulls ?? [],
|
|
5554
|
+
Date.now()
|
|
5555
|
+
);
|
|
5122
5556
|
}
|
|
5123
5557
|
ensureSlot() {
|
|
5124
5558
|
if (this.slotsFull()) {
|
|
@@ -5197,34 +5631,18 @@ var Swarm = class {
|
|
|
5197
5631
|
t.attach(ws);
|
|
5198
5632
|
}
|
|
5199
5633
|
buildSystemAppend(a, repo, cwd, branch, fixing) {
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
`Your worktree: ${cwd}`,
|
|
5213
|
-
fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
|
|
5214
|
-
linked.length ? `Related repositories you may read for context (do not modify them):
|
|
5215
|
-
${linked.join("\n")}` : "",
|
|
5216
|
-
"",
|
|
5217
|
-
"Workflow:",
|
|
5218
|
-
"1. Read the issue and explore the relevant code before changing anything.",
|
|
5219
|
-
"2. Implement the change with focused commits and clear messages.",
|
|
5220
|
-
"3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
|
|
5221
|
-
repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${this.port(a)} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
|
|
5222
|
-
`5. Push: ${push}`,
|
|
5223
|
-
fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
|
|
5224
|
-
fixing ? "" : "7. End your final message with the pull request URL on its own line.",
|
|
5225
|
-
"",
|
|
5226
|
-
"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."
|
|
5227
|
-
].filter((l) => l !== "").join("\n");
|
|
5634
|
+
return devSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, linked: this.linkedDirs(repo), fixing });
|
|
5635
|
+
}
|
|
5636
|
+
linkedDirs(repo) {
|
|
5637
|
+
return this.linkedRepos(repo).map((r) => ({ fullName: r.fullName, dir: this.backend.mainDir(r.fullName) }));
|
|
5638
|
+
}
|
|
5639
|
+
/** What an agent is told on a task, previewed with placeholders for the task's details. */
|
|
5640
|
+
agentPrompt(id) {
|
|
5641
|
+
const a = this.agent(id);
|
|
5642
|
+
if (a.role === "ceo") return ceoPromptPreview(ceoSystemPrompt(this.ceoPromptInput(a)));
|
|
5643
|
+
const repo = this.repo(a.repoId);
|
|
5644
|
+
const base = { agent: a, repo, port: this.port(a), slug: slugify(a.name) };
|
|
5645
|
+
return a.role === "qa" ? qaPromptPreview(base) : devPromptPreview({ ...base, linked: this.linkedDirs(repo) });
|
|
5228
5646
|
}
|
|
5229
5647
|
beginTask(a, patch, banner, preparing) {
|
|
5230
5648
|
const rt = this.agentRt.get(a.id);
|
|
@@ -5252,8 +5670,13 @@ ${linked.join("\n")}` : "",
|
|
|
5252
5670
|
async prepare(a, repo, base, branch) {
|
|
5253
5671
|
try {
|
|
5254
5672
|
if (this.repoRt.get(repo.id)?.cloneStatus !== "ready") await this.cloneRepo(repo.id);
|
|
5255
|
-
|
|
5256
|
-
const
|
|
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);
|
|
5257
5680
|
return a.status === "preparing" ? cwd : null;
|
|
5258
5681
|
} catch (err) {
|
|
5259
5682
|
if (a.status !== "preparing") return null;
|
|
@@ -5261,13 +5684,17 @@ ${linked.join("\n")}` : "",
|
|
|
5261
5684
|
a.endedAt = Date.now();
|
|
5262
5685
|
a.lastError = err.message;
|
|
5263
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
|
+
}
|
|
5264
5691
|
this.emitAgent(a);
|
|
5265
5692
|
this.save();
|
|
5266
5693
|
return null;
|
|
5267
5694
|
}
|
|
5268
5695
|
}
|
|
5269
5696
|
async runTask(a, repo, issue2, note) {
|
|
5270
|
-
const branch =
|
|
5697
|
+
const branch = devBranch(issue2.number, slugify(a.name));
|
|
5271
5698
|
this.beginTask(
|
|
5272
5699
|
a,
|
|
5273
5700
|
{ task: "issue", issueNumber: issue2.number, issueTitle: issue2.title, branch, prNumber: null, prUrl: null, sessionId: null },
|
|
@@ -5468,33 +5895,11 @@ Note from the manager: ${note}` : ""
|
|
|
5468
5895
|
this.queueQa(repo, prNumber, dev ?? null, pr.closesIssues[0] ?? null);
|
|
5469
5896
|
this.toast("info", `PR #${prNumber} is queued for QA`);
|
|
5470
5897
|
}
|
|
5471
|
-
buildQaSystemAppend(a, repo, cwd, branch, pr) {
|
|
5472
|
-
return
|
|
5473
|
-
`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.`,
|
|
5474
|
-
...a.brief ? ["", `Your job description:
|
|
5475
|
-
${a.brief}`] : [],
|
|
5476
|
-
...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
|
|
5477
|
-
"",
|
|
5478
|
-
`Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
|
|
5479
|
-
...repo.summary ? [`Project: ${repo.summary}`] : [],
|
|
5480
|
-
...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
|
|
5481
|
-
...repo.qaBrief ? [`What to check on this project (from the CEO):
|
|
5482
|
-
${repo.qaBrief}`] : [],
|
|
5483
|
-
`Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
|
|
5484
|
-
`Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
|
|
5485
|
-
"",
|
|
5486
|
-
"How to test:",
|
|
5487
|
-
"1. Read the PR description and the linked issue, and work out the acceptance criteria.",
|
|
5488
|
-
`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.`,
|
|
5489
|
-
"3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
|
|
5490
|
-
repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${this.port(a)} (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).",
|
|
5491
|
-
"5. You may write throwaway scripts to probe behaviour, but do not commit them.",
|
|
5492
|
-
"",
|
|
5493
|
-
"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."
|
|
5494
|
-
].join("\n");
|
|
5898
|
+
buildQaSystemAppend(a, repo, cwd, branch, pr, testStep2) {
|
|
5899
|
+
return qaSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, pr, testStep: testStep2 });
|
|
5495
5900
|
}
|
|
5496
5901
|
async runQa(a, repo, rec) {
|
|
5497
|
-
const branch =
|
|
5902
|
+
const branch = qaBranch(rec.prNumber, slugify(a.name));
|
|
5498
5903
|
this.setQa(rec, { status: "testing", qaAgentId: a.id });
|
|
5499
5904
|
this.beginTask(
|
|
5500
5905
|
a,
|
|
@@ -5504,6 +5909,7 @@ ${repo.qaBrief}`] : [],
|
|
|
5504
5909
|
);
|
|
5505
5910
|
let pr;
|
|
5506
5911
|
let issue2 = null;
|
|
5912
|
+
const lastTestedSha = rec.testedSha;
|
|
5507
5913
|
try {
|
|
5508
5914
|
pr = await this.backend.prDetails(repo.fullName, rec.prNumber);
|
|
5509
5915
|
const issueNumber = rec.issueNumber ?? pr.closesIssues[0] ?? null;
|
|
@@ -5533,16 +5939,22 @@ ${repo.qaBrief}`] : [],
|
|
|
5533
5939
|
return;
|
|
5534
5940
|
}
|
|
5535
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
|
+
});
|
|
5536
5952
|
const prompt = [
|
|
5537
5953
|
`Please QA pull request #${pr.number}: ${pr.title}`,
|
|
5538
5954
|
`URL: ${pr.url}`,
|
|
5539
5955
|
`Author: ${dev ? `${dev.name} (developer agent)` : "a teammate"} \xB7 QA round ${rec.round}`,
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
This is a re-test after fixes. Last round's findings:
|
|
5543
|
-
${rec.summary}
|
|
5544
|
-
${rec.fixInstructions ?? ""}
|
|
5545
|
-
Check those first, then re-check everything else.` : "",
|
|
5956
|
+
qa.checks,
|
|
5957
|
+
qa.retest,
|
|
5546
5958
|
"",
|
|
5547
5959
|
"PR description:",
|
|
5548
5960
|
pr.body.trim() || "(empty)",
|
|
@@ -5550,7 +5962,7 @@ Check those first, then re-check everything else.` : "",
|
|
|
5550
5962
|
Linked issue: ${issue2.title}
|
|
5551
5963
|
${issue2.body.trim() || "(no description)"}` : ""
|
|
5552
5964
|
].filter((l) => l !== "").join("\n");
|
|
5553
|
-
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);
|
|
5554
5966
|
}
|
|
5555
5967
|
async onQaFinished(a, repo, result) {
|
|
5556
5968
|
const rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === a.prNumber);
|
|
@@ -5574,22 +5986,25 @@ ${issue2.body.trim() || "(no description)"}` : ""
|
|
|
5574
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` }]);
|
|
5575
5987
|
let commentUrl = null;
|
|
5576
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);
|
|
5577
5991
|
try {
|
|
5578
5992
|
this.appendLog(a, [{ kind: "system", text: "\u{1F4CE} Uploading evidence and posting the QA report on the PR\u2026" }]);
|
|
5579
|
-
const body = await this.renderQaComment(a, repo, rec, report, rt.shots);
|
|
5993
|
+
const body = await this.renderQaComment(a, repo, rec, report, rt.shots, next);
|
|
5580
5994
|
commentUrl = await this.backend.commentPull(repo.fullName, rec.prNumber, body) || null;
|
|
5581
5995
|
this.appendLog(a, [{ kind: "system", text: ` \u23BF ${commentUrl ?? "comment posted"}` }]);
|
|
5582
5996
|
} catch (err) {
|
|
5583
5997
|
this.appendLog(a, [{ kind: "error", text: ` \u23BF Could not post the QA report: ${err.message}` }]);
|
|
5584
5998
|
}
|
|
5585
5999
|
const qaRounds = rec.round - rec.retests;
|
|
5586
|
-
const nextStatus =
|
|
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");
|
|
5587
6002
|
this.setQa(rec, {
|
|
5588
6003
|
status: nextStatus,
|
|
5589
6004
|
summary: report.summary,
|
|
5590
6005
|
checks: report.checks,
|
|
5591
6006
|
commentUrl: commentUrl ?? rec.commentUrl,
|
|
5592
|
-
fixInstructions
|
|
6007
|
+
fixInstructions,
|
|
5593
6008
|
sessionFailures: 0,
|
|
5594
6009
|
fixReason: pass ? null : "qa",
|
|
5595
6010
|
passedSha: pass ? rec.testedSha : null,
|
|
@@ -5598,13 +6013,14 @@ ${issue2.body.trim() || "(no description)"}` : ""
|
|
|
5598
6013
|
mergeRetryAt: null,
|
|
5599
6014
|
alerted: false
|
|
5600
6015
|
});
|
|
6016
|
+
if (next === "conflict") this.sendBack(repo, rec, "conflict", conflictFixInstructions(fixInstructions, repo.defaultBranch), false);
|
|
5601
6017
|
this.toast(
|
|
5602
6018
|
pass ? "success" : "error",
|
|
5603
|
-
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`
|
|
5604
6020
|
);
|
|
5605
6021
|
}
|
|
5606
6022
|
}
|
|
5607
|
-
async renderQaComment(a, repo, rec, report, shots) {
|
|
6023
|
+
async renderQaComment(a, repo, rec, report, shots, next) {
|
|
5608
6024
|
const pass = report.verdict === "pass";
|
|
5609
6025
|
const images = [];
|
|
5610
6026
|
const evidence = shots.slice(-8);
|
|
@@ -5642,7 +6058,7 @@ ${issue2.body.trim() || "(no description)"}` : ""
|
|
|
5642
6058
|
if (images.length) lines.push("", "### \u{1F4F8} Evidence", "", ...images.flatMap((img) => [img, ""]));
|
|
5643
6059
|
else lines.push("", "_No browser screenshots were taken in this round._");
|
|
5644
6060
|
const merge = repo.autoMerge ? "merges automatically once GitHub's checks pass" : "ready for the manager to merge";
|
|
5645
|
-
lines.push("", `<sub>Posted by cubefarm \xB7 ${pass ? merge :
|
|
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>`);
|
|
5646
6062
|
return lines.join("\n");
|
|
5647
6063
|
}
|
|
5648
6064
|
/** Send a failed PR back to the developer who wrote it (or any free developer on the floor). */
|
|
@@ -5667,7 +6083,7 @@ ${issue2.body.trim() || "(no description)"}` : ""
|
|
|
5667
6083
|
const failed2 = rec.checks.filter((c) => c.result === "fail");
|
|
5668
6084
|
const takeover = original ? "" : " A teammate wrote it, so read the PR and the linked issue first.";
|
|
5669
6085
|
const push = `push to the same branch: git push origin HEAD:${headRef}`;
|
|
5670
|
-
const mergeFix = rec.fixReason === "conflict" ? [
|
|
6086
|
+
const mergeFix = rec.fixReason === "conflict" && rec.passedSha ? [
|
|
5671
6087
|
`QA passed pull request #${rec.prNumber} (${pull?.url ?? ""}), but it now conflicts with ${repo.defaultBranch} because other work was merged first.${takeover}`,
|
|
5672
6088
|
"",
|
|
5673
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}`
|
|
@@ -6010,11 +6426,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
|
|
|
6010
6426
|
}
|
|
6011
6427
|
emitOfficeUpdate() {
|
|
6012
6428
|
if (!this.officeHead) return;
|
|
6013
|
-
const
|
|
6014
|
-
const key = JSON.stringify(
|
|
6429
|
+
const view2 = this.officeUpdateView();
|
|
6430
|
+
const key = JSON.stringify(view2);
|
|
6015
6431
|
if (key === this.lastOfficeView) return;
|
|
6016
6432
|
this.lastOfficeView = key;
|
|
6017
|
-
this.broadcast({ type: "officeUpdate", officeUpdate:
|
|
6433
|
+
this.broadcast({ type: "officeUpdate", officeUpdate: view2 });
|
|
6018
6434
|
}
|
|
6019
6435
|
/** The own-folder check found the office this many commits behind GitHub. */
|
|
6020
6436
|
setOfficeBehind(behind) {
|
|
@@ -6168,11 +6584,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
|
|
|
6168
6584
|
}
|
|
6169
6585
|
ceoInfo() {
|
|
6170
6586
|
const c = this.state.ceo;
|
|
6171
|
-
const
|
|
6587
|
+
const view2 = (j) => ({ kind: j.kind, label: jobLabel(j, this.ceoFloor(j.repoId)) });
|
|
6172
6588
|
const min = this.state.settings.ceoHeartbeatMin;
|
|
6173
6589
|
return {
|
|
6174
|
-
queue: c.queue.map(
|
|
6175
|
-
job: c.job ?
|
|
6590
|
+
queue: c.queue.map(view2),
|
|
6591
|
+
job: c.job ? view2(c.job) : null,
|
|
6176
6592
|
lastReviewAt: c.lastReviewAt,
|
|
6177
6593
|
nextReviewAt: min > 0 && c.lastReviewAt ? c.lastReviewAt + min * 6e4 : null
|
|
6178
6594
|
};
|
|
@@ -6216,6 +6632,10 @@ ${job.text}` } : job;
|
|
|
6216
6632
|
}
|
|
6217
6633
|
void this.runCeoJob(a, job);
|
|
6218
6634
|
}
|
|
6635
|
+
ceoPromptInput(a) {
|
|
6636
|
+
const s = this.state.settings;
|
|
6637
|
+
return { name: a.name, company: s.companyName, manager: s.managerName, notesFile: path13.join(CEO_DIR, "NOTES.md"), sessionLimit: s.sessionLimit, teamCap: s.teamCap, hiring: s.hiring };
|
|
6638
|
+
}
|
|
6219
6639
|
async runCeoJob(a, job) {
|
|
6220
6640
|
const rt = this.agentRt.get(a.id);
|
|
6221
6641
|
const floor = this.ceoFloor(job.repoId);
|
|
@@ -6241,21 +6661,12 @@ ${job.text}` } : job;
|
|
|
6241
6661
|
this.emitCeo();
|
|
6242
6662
|
return;
|
|
6243
6663
|
}
|
|
6244
|
-
const s = this.state.settings;
|
|
6245
6664
|
const how = this.sessionRuntime(a, job.kind === "chat" ? a.sessionId ?? void 0 : void 0);
|
|
6246
6665
|
rt.session = this.backend.startSession(
|
|
6247
6666
|
{
|
|
6248
6667
|
cwd: CEO_DIR,
|
|
6249
6668
|
prompt: ceoJobPrompt(job, floor),
|
|
6250
|
-
systemAppend: ceoSystemPrompt(
|
|
6251
|
-
name: a.name,
|
|
6252
|
-
company: s.companyName,
|
|
6253
|
-
manager: s.managerName,
|
|
6254
|
-
notesFile: path13.join(CEO_DIR, "NOTES.md"),
|
|
6255
|
-
sessionLimit: s.sessionLimit,
|
|
6256
|
-
teamCap: s.teamCap,
|
|
6257
|
-
hiring: s.hiring
|
|
6258
|
-
}),
|
|
6669
|
+
systemAppend: ceoSystemPrompt(this.ceoPromptInput(a)),
|
|
6259
6670
|
model: a.model || CEO_MODEL,
|
|
6260
6671
|
effort: a.effort || CEO_EFFORT,
|
|
6261
6672
|
browserTesting: false,
|
|
@@ -6848,6 +7259,7 @@ app.post("/api/agents/:id/assign", route((req) => swarm.assign(String(req.params
|
|
|
6848
7259
|
app.post("/api/agents/:id/stop", route((req) => swarm.stopAgent(String(req.params.id))));
|
|
6849
7260
|
app.post("/api/agents/:id/reset", route((req) => swarm.resetAgent(String(req.params.id))));
|
|
6850
7261
|
app.post("/api/agents/:id/message", route((req) => swarm.message(String(req.params.id), str(req.body.text))));
|
|
7262
|
+
app.get("/api/agents/:id/prompt", route((req) => swarm.agentPrompt(String(req.params.id))));
|
|
6851
7263
|
app.get("/api/agents/:id/screen", (req, res) => {
|
|
6852
7264
|
const shot = swarm.screenshot(String(req.params.id));
|
|
6853
7265
|
if (!shot) return void res.status(404).end();
|