huddle-cli 0.1.0 → 0.1.1
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/CHANGELOG.md +31 -0
- package/QUICKSTART.md +102 -0
- package/README.md +23 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +6 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/attach/connect.js +4 -1
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +14 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +108 -40
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +38 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +13 -3
- package/dist/orchestrator/council.js +427 -86
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +29 -13
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +25 -7
- package/dist/orchestrator/qa.js +62 -14
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +498 -39
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +273 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +169 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +554 -133
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +194 -51
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +666 -69
- package/dist/ui/term.js +151 -16
- package/dist/ui/watch.js +145 -48
- package/package.json +6 -3
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
// runs the test gate; in manual mode you approve the merge; landing it can
|
|
6
6
|
// unblock more tasks, so we fill again. Nobody idles while there's work.
|
|
7
7
|
import { launchAgent, stopChild } from "./launcher.js";
|
|
8
|
-
import { prepareMerge, landMerge, removeWorktree, branchDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
|
|
8
|
+
import { prepareMerge, landMerge, removeWorktree, branchDiff, wipDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
|
|
9
9
|
import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
|
|
10
10
|
import { UsageMeter } from "../adapters/usage.js";
|
|
11
11
|
import { askAgent } from "./ask.js";
|
|
12
12
|
import { handoffsFor, headSha, recordHandoff } from "./handoff.js";
|
|
13
|
-
import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv } from "./takeover.js";
|
|
14
|
-
import { helpOffer, alongsideBrief } from "../room/progress.js";
|
|
13
|
+
import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv, pairWaitMsFromEnv, buddyAfterMsFromEnv, isSpecialTask, buddyPrompt, parseBuddy, pairPrompt, parsePart, testsPart, pairNote, BUDDY_AT, pairEligible, } from "./takeover.js";
|
|
14
|
+
import { helpOffer, alongsideBrief, addSplitPart, taskViews } from "../room/progress.js";
|
|
15
|
+
import { contractBrief, contractNames, getContract } from "../room/contract.js";
|
|
16
|
+
import { notifyAgent } from "../room/mail.js";
|
|
17
|
+
import { closeNotes, openNotes, resolveNotes, trackNote } from "../room/followup.js";
|
|
18
|
+
import { onFinished } from "../platform.js";
|
|
19
|
+
import { startRadar } from "./radar.js";
|
|
15
20
|
const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
|
|
16
21
|
/** No help offers on a task whose reported progress is this far along. */
|
|
17
22
|
export const OFFER_MAX_PCT = 60;
|
|
@@ -46,18 +51,20 @@ HOW:
|
|
|
46
51
|
resolution and re-runs the tests before anything lands.
|
|
47
52
|
4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
|
|
48
53
|
}
|
|
49
|
-
function reviewPrompt(title, spec, diff) {
|
|
54
|
+
function reviewPrompt(title, spec, diff, contract = "") {
|
|
50
55
|
return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
|
|
51
56
|
|
|
52
57
|
TASK: ${title}
|
|
53
58
|
${spec}
|
|
54
|
-
|
|
59
|
+
${contract ? `\n${cap(contract, 2500)}\n` : ""}
|
|
55
60
|
DIFF (against main):
|
|
56
61
|
${diff || "(empty diff)"}
|
|
57
62
|
|
|
58
63
|
You have NO tools — you can't run commands or open files, and you don't need to: the task and the full diff are above.
|
|
59
|
-
Check the
|
|
64
|
+
Check, in order: (1) names and shapes match the ${contract ? "contract and the " : ""}task (functions, fields, routes, messages);
|
|
65
|
+
(2) each acceptance point in the task is actually done; (3) error paths (bad input, missing data) behave; (4) the tests actually exercise this change.
|
|
60
66
|
Reply with the first line exactly APPROVE or CHANGES.
|
|
67
|
+
If APPROVE, add one line "Verified: <what you checked and found working>".
|
|
61
68
|
If CHANGES, add up to 6 short lines, each one concrete blocking problem and where it is.
|
|
62
69
|
Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you have. Do not edit anything.`;
|
|
63
70
|
}
|
|
@@ -65,14 +72,19 @@ Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you
|
|
|
65
72
|
* The first APPROVE/CHANGES line decides. Anything else is "skipped" — shown
|
|
66
73
|
* honestly, and it never blocks a merge (a reviewer can't block by accident).
|
|
67
74
|
*/
|
|
68
|
-
function parseVerdict(text) {
|
|
75
|
+
export function parseVerdict(text) {
|
|
69
76
|
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
70
|
-
|
|
77
|
+
// "APPROVED" is an approve too (it used to read as no verdict at all).
|
|
78
|
+
const i = lines.findIndex((l) => /^(APPROVED?|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
|
|
71
79
|
if (i < 0)
|
|
72
80
|
return { approve: true, skipped: true, notes: "no verdict" };
|
|
73
81
|
if (lines[i].replace(/[*`#_]/g, "").toUpperCase().startsWith("CHANGES"))
|
|
74
82
|
return { approve: false, notes: lines.slice(i + 1, i + 7).join("\n") || "(no details given)" };
|
|
75
|
-
|
|
83
|
+
// Found live: ~9s bare APPROVEs. An approve that says nothing about what was verified is shown as shallow.
|
|
84
|
+
const said = [lines[i].replace(/[*`#_]/g, "").replace(/^\s*APPROVE[D]?\b[\s:.,!—-]*/i, ""), ...lines.slice(i + 1, i + 3)].join(" ").trim();
|
|
85
|
+
// "Verified: ok" / "LGTM" say nothing either: what's left after the label has to name something.
|
|
86
|
+
const what = said.replace(/^(verified|checked)\s*:?\s*/i, "").replace(/^(lgtm|looks good|ok|fine)\b[\s.!,]*/i, "");
|
|
87
|
+
return what.length >= 10 ? { approve: true, notes: said } : { approve: true, shallow: true, notes: "approve (shallow)" };
|
|
76
88
|
}
|
|
77
89
|
export async function runBoard(opts) {
|
|
78
90
|
const { db, repoRoot, roomUrl, workers, testCmd, signal } = opts;
|
|
@@ -90,6 +102,16 @@ export async function runBoard(opts) {
|
|
|
90
102
|
}
|
|
91
103
|
opts.onEvent(e);
|
|
92
104
|
};
|
|
105
|
+
// The shared contract, when the Council wrote one ("" when there is none).
|
|
106
|
+
const contractOf = opts.contractBrief ?? contractBrief;
|
|
107
|
+
const contract = () => {
|
|
108
|
+
try {
|
|
109
|
+
return contractOf(db) || "";
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
};
|
|
93
115
|
const running = new Map(); // agent id -> handle
|
|
94
116
|
let merged = 0;
|
|
95
117
|
let failed = 0;
|
|
@@ -112,11 +134,17 @@ export async function runBoard(opts) {
|
|
|
112
134
|
return new Promise((resolve) => {
|
|
113
135
|
let resolved = false;
|
|
114
136
|
let stopWatch = () => { }; // the silence watchdog + help offers (set up below)
|
|
137
|
+
let stopRadar = () => { }; // the integration radar (set up below)
|
|
138
|
+
// Buddy checks and pairing asks end with the run (or with you): no ask outlives it.
|
|
139
|
+
const sideAsks = new AbortController();
|
|
140
|
+
signal?.addEventListener("abort", () => sideAsks.abort());
|
|
115
141
|
const done = () => {
|
|
116
142
|
if (resolved)
|
|
117
143
|
return;
|
|
118
144
|
resolved = true;
|
|
145
|
+
sideAsks.abort();
|
|
119
146
|
stopWatch();
|
|
147
|
+
stopRadar();
|
|
120
148
|
process.off("exit", killAll);
|
|
121
149
|
resolve({ merged, failed, stopped: !!signal?.aborted });
|
|
122
150
|
};
|
|
@@ -233,7 +261,8 @@ export async function runBoard(opts) {
|
|
|
233
261
|
agentId: r.id,
|
|
234
262
|
role: "worker",
|
|
235
263
|
taskId: task.id,
|
|
236
|
-
|
|
264
|
+
// The contract decides a name clash: both sides code to it.
|
|
265
|
+
taskSpec: `Resolve merge conflict: ${task.title}\n\n${resolvePrompt({ title: task.title, spec: task.spec, brief, others, retry: st.last })}${contractBrief(db) ? `\n\n${contractBrief(db)}` : ""}`,
|
|
237
266
|
roomUrl,
|
|
238
267
|
roomSecret: opts.roomSecret,
|
|
239
268
|
approvals: opts.approvals,
|
|
@@ -245,15 +274,8 @@ export async function runBoard(opts) {
|
|
|
245
274
|
running.set(r.id, h2);
|
|
246
275
|
heard(r.id, h2);
|
|
247
276
|
onEvent({ type: "conflict", agent: r.id, author: c.author.id, task: task.id, title: task.title, files: cs.files, attempt: st.n, log: h2.logPath });
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (exited)
|
|
251
|
-
return;
|
|
252
|
-
exited = true;
|
|
253
|
-
void endResolve(r, c, h2, cs, code);
|
|
254
|
-
};
|
|
255
|
-
h2.child.on("exit", once);
|
|
256
|
-
h2.child.on("error", () => once(-1));
|
|
277
|
+
// On 'close' (capped): its last stdout lines (token usage) can still be in the pipe at 'exit'.
|
|
278
|
+
onFinished(h2.child, (code) => void endResolve(r, c, h2, cs, code));
|
|
257
279
|
};
|
|
258
280
|
const endResolve = async (r, c, h2, cs, code) => {
|
|
259
281
|
const ms = Date.now() - h2.startedAt;
|
|
@@ -380,18 +402,333 @@ export async function runBoard(opts) {
|
|
|
380
402
|
continue;
|
|
381
403
|
const span = quiet >= 60_000 ? `${Math.round(quiet / 60_000)} min` : `${Math.round(quiet / 1000)}s`;
|
|
382
404
|
silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span}`);
|
|
383
|
-
h.child
|
|
384
|
-
|
|
385
|
-
|
|
405
|
+
stopChild(h.child);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
// --- teamwork mid-task --------------------------------------------------
|
|
409
|
+
// Found live (3 capstone runs): every help offer was declined, no split
|
|
410
|
+
// ever happened, and reviews came only after huddle_done. So Huddle acts:
|
|
411
|
+
// buddy checks put a second agent's eyes on work in progress, and pairing
|
|
412
|
+
// hands a part of the slowest task to a free agent unless its author says no.
|
|
413
|
+
const buddyOn = opts.buddyChecks ?? !/^(off|no|0|false)$/i.test(process.env.HUDDLE_BUDDY ?? "");
|
|
414
|
+
const pairWaitMs = opts.pairWaitMs ?? pairWaitMsFromEnv();
|
|
415
|
+
const buddyAfterMs = opts.buddyAfterMs !== undefined ? opts.buddyAfterMs : buddyAfterMsFromEnv();
|
|
416
|
+
const buddyBy = new Map(); // agent id -> buddy checks it gave
|
|
417
|
+
const buddyDone = new Map(); // task id -> its checks so far (and the first one's pct)
|
|
418
|
+
const buddyBusy = new Set(); // task ids with a check in flight
|
|
419
|
+
const buddyRuns = new Map(); // task id -> its check in flight, for finish() to wait on
|
|
420
|
+
const buddyWaitMs = opts.buddyWaitMs ?? 60_000;
|
|
421
|
+
const followedUp = new Set(); // task ids that had their one round for notes that came too late
|
|
422
|
+
const paired = new Set(); // task ids that had their one pairing (or attempt)
|
|
423
|
+
const pairing = new Set(); // helper ids mid-pairing (asking, or waiting for a no)
|
|
424
|
+
const holdFor = new Map(); // task id -> the partner task that must land first
|
|
425
|
+
const testsOf = new Set(); // tests-part task ids (they test their partner's code)
|
|
426
|
+
const held = []; // finished, waiting for their partner to land
|
|
427
|
+
const settled = (id) => {
|
|
428
|
+
const st = db.getTask(id)?.status;
|
|
429
|
+
return !st || st === "merged" || st === "failed" || st === "needs_you";
|
|
430
|
+
};
|
|
431
|
+
/** The other tasks in flight, with their work so far (capped): where a buddy spots XYZ vs ABC. */
|
|
432
|
+
const othersInFlight = (taskId) => [...running.values()]
|
|
433
|
+
.filter((x) => x.taskId !== taskId && x.child)
|
|
434
|
+
.slice(0, 3)
|
|
435
|
+
.map((x) => {
|
|
436
|
+
const t = db.getTask(x.taskId);
|
|
437
|
+
const wip = wipDiff(repoRoot, x.worktree, x.excludes, 2500);
|
|
438
|
+
return `- ${x.agentId}: “${t?.title ?? x.taskId}” (${x.taskId}) · files so far: ${wip.files.slice(0, 10).join(", ") || "(none yet)"}${wip.diff ? `\n${wip.diff}` : ""}`;
|
|
439
|
+
})
|
|
440
|
+
.join("\n");
|
|
441
|
+
/** Never the author; a free agent first, then the one that gave the fewest buddy checks. */
|
|
442
|
+
const pickBuddy = (authorId) => workers
|
|
443
|
+
.filter((x) => x.id !== authorId && x.adapter?.ask)
|
|
444
|
+
.map((w, i) => ({ w, i, busy: running.has(w.id) || reserved.has(w.id) ? 1 : 0 }))
|
|
445
|
+
.sort((a, b) => a.busy - b.busy || (buddyBy.get(a.w.id) ?? 0) - (buddyBy.get(b.w.id) ?? 0) || a.i - b.i)[0]?.w;
|
|
446
|
+
/** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
|
|
447
|
+
const buddyCheck = async (taskId, pct, silentMs = 0) => {
|
|
448
|
+
if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId))
|
|
449
|
+
return;
|
|
450
|
+
const t = db.getTask(taskId);
|
|
451
|
+
if (!t?.claimed_by || isSpecialTask(t))
|
|
452
|
+
return;
|
|
453
|
+
const author = t.claimed_by;
|
|
454
|
+
const h = running.get(author);
|
|
455
|
+
if (!h || h.taskId !== taskId || !h.child)
|
|
456
|
+
return; // local, and mid-task
|
|
457
|
+
const st = buddyDone.get(taskId) ?? { n: 0, notes: false, at: 0 };
|
|
458
|
+
// The second look is a follow-up on the first one's notes; a first look at 80% already was the late one.
|
|
459
|
+
// silentMs: the author never reported progress (found live: Claude called
|
|
460
|
+
// huddle_progress twice in 4 tasks), so the first look goes by time instead.
|
|
461
|
+
const due = st.n === 0 ? pct >= BUDDY_AT[0] || silentMs > 0 : st.n === 1 && st.notes && st.at < BUDDY_AT[1] && pct >= BUDDY_AT[1];
|
|
462
|
+
if (!due)
|
|
463
|
+
return;
|
|
464
|
+
const buddy = pickBuddy(author);
|
|
465
|
+
if (!buddy)
|
|
466
|
+
return;
|
|
467
|
+
// Nothing written yet: nothing to look at, and not worth a teammate's tokens. The next report tries again.
|
|
468
|
+
const wip = wipDiff(repoRoot, h.worktree, h.excludes, 16_000);
|
|
469
|
+
if (!wip.diff)
|
|
470
|
+
return;
|
|
471
|
+
st.n++;
|
|
472
|
+
if (st.n === 1)
|
|
473
|
+
st.at = pct;
|
|
474
|
+
buddyDone.set(taskId, st);
|
|
475
|
+
buddyBusy.add(taskId);
|
|
476
|
+
buddyBy.set(buddy.id, (buddyBy.get(buddy.id) ?? 0) + 1);
|
|
477
|
+
const t0 = Date.now();
|
|
478
|
+
let landed = () => { };
|
|
479
|
+
buddyRuns.set(taskId, { landed: new Promise((r) => (landed = r)), at: t0 });
|
|
480
|
+
try {
|
|
481
|
+
// The Room's huddle_done waits on this pair (server.ts), so the notes reach the author before it exits.
|
|
482
|
+
db.publish("buddy.start", { task_id: taskId, agent_id: buddy.id, payload: { author, pct } });
|
|
483
|
+
const when = silentMs ? `${Math.round(silentMs / 60_000) || 1} min in` : `${pct}%`;
|
|
484
|
+
const prompt = buddyPrompt({ author, when, title: t.title, spec: t.spec, contract: contract(), others: othersInFlight(taskId), diff: wip.diff });
|
|
485
|
+
// Its own signal, tied to the run's: askAgent never unhooks, and a long run would pile listeners on one signal.
|
|
486
|
+
const stop = new AbortController();
|
|
487
|
+
const off = () => stop.abort();
|
|
488
|
+
sideAsks.signal.addEventListener("abort", off);
|
|
489
|
+
const res = await askAgent(buddy.adapter, prompt, repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => sideAsks.signal.removeEventListener("abort", off));
|
|
490
|
+
if (resolved || !res.ok)
|
|
491
|
+
return; // no answer: the author never knew it was asked
|
|
492
|
+
const notes = parseBuddy(res.text);
|
|
493
|
+
st.notes = !!notes;
|
|
494
|
+
// Its author is past it (finish() waited buddyWaitMs; it's merging or landed): the notes would only reach the
|
|
495
|
+
// author's NEXT task, where they mean nothing. Shared memory keeps them for QA and later tasks.
|
|
496
|
+
const st2 = db.getTask(taskId)?.status;
|
|
497
|
+
const late = !!notes && !!st2 && !["claimed", "in_progress"].includes(st2);
|
|
498
|
+
if (notes && late)
|
|
499
|
+
db.remember({ kind: "gotcha", body: `Buddy note on "${t.title}" from ${buddy.id} (came back after it landed; nobody acted on it): ${notes}`.slice(0, 1500), author: buddy.id });
|
|
500
|
+
else if (notes) {
|
|
501
|
+
const m = notifyAgent(db, author, `buddy check from ${buddy.id} ${silentMs ? when : `at ${when}`}:\n${notes}`, "huddle", taskId);
|
|
502
|
+
trackNote(db, m, { by: buddy.id, kind: "buddy", worktree: h.worktree });
|
|
503
|
+
}
|
|
504
|
+
onEvent({ type: "checkpoint", agent: buddy.id, author, task: taskId, title: t.title, pct, notes: notes ?? "LOOKS GOOD", ms: Date.now() - t0, ...(late ? { late } : {}) });
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
/* a buddy check never breaks a run */
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
buddyBusy.delete(taskId);
|
|
511
|
+
buddyRuns.delete(taskId);
|
|
512
|
+
try {
|
|
513
|
+
db.publish("buddy.end", { task_id: taskId, agent_id: buddy.id });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
/* closed db */
|
|
517
|
+
}
|
|
518
|
+
landed();
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
/** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
|
|
522
|
+
const buddyBySilence = () => {
|
|
523
|
+
if (buddyAfterMs === null || !buddyOn)
|
|
524
|
+
return;
|
|
525
|
+
const now = Date.now();
|
|
526
|
+
for (const h of running.values()) {
|
|
527
|
+
if (!h.child || buddyDone.has(h.taskId) || buddyBusy.has(h.taskId) || now - h.startedAt < buddyAfterMs)
|
|
528
|
+
continue;
|
|
529
|
+
// Reached 40%: that report triggers it. One early "10%" and then quiet still counts as quiet.
|
|
530
|
+
const pct = db.getTask(h.taskId)?.progress_pct ?? 0;
|
|
531
|
+
if (pct >= BUDDY_AT[0])
|
|
532
|
+
continue;
|
|
533
|
+
void buddyCheck(h.taskId, pct, now - h.startedAt).catch(() => { });
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
/** Resolves with the author's "no: …" reason, or null when the window closes (or it says yes, or stops mattering). */
|
|
537
|
+
const waitForNo = (author, helper, since, ms, still) => new Promise((resolve) => {
|
|
538
|
+
let over = false;
|
|
539
|
+
const end = (v) => {
|
|
540
|
+
if (over)
|
|
541
|
+
return;
|
|
542
|
+
over = true;
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
clearInterval(poll);
|
|
545
|
+
unsub();
|
|
546
|
+
resolve(v);
|
|
547
|
+
};
|
|
548
|
+
const look = () => {
|
|
549
|
+
if (over)
|
|
550
|
+
return;
|
|
551
|
+
for (const m of db.inbox("huddle", { peek: true })) {
|
|
552
|
+
if (m.from_agent !== author || m.created_at < since)
|
|
553
|
+
continue;
|
|
554
|
+
db.markRead(m.id);
|
|
555
|
+
const body = m.body.replace(/[*`_]/g, "").trim();
|
|
556
|
+
if (/^(yes|y|ok|okay|go|sure|fine|sounds good|go ahead)\b/i.test(body))
|
|
557
|
+
return end(null);
|
|
558
|
+
// Anything else the author sends Huddle in the window is an objection: it was asked for a no, not a chat.
|
|
559
|
+
return end(body.replace(/^(no|nope|nah|don't|do not)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
|
|
560
|
+
}
|
|
561
|
+
// Models mix up recipients: a "no" sent to the helper itself counts too (left unread for the helper).
|
|
562
|
+
for (const m of db.inbox(helper, { peek: true })) {
|
|
563
|
+
if (m.from_agent !== author || m.created_at < since)
|
|
564
|
+
continue;
|
|
565
|
+
const body = m.body.replace(/[*`_]/g, "").trim();
|
|
566
|
+
if (/^(no|nope|nah)\b/i.test(body))
|
|
567
|
+
return end(body.replace(/^(no|nope|nah)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
const unsub = db.onEvent((e) => {
|
|
571
|
+
if (e.type !== "agent.message")
|
|
572
|
+
return;
|
|
573
|
+
try {
|
|
574
|
+
look();
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
/* a read that fails is just no reply yet */
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
const timer = setTimeout(() => end(null), ms);
|
|
581
|
+
const poll = setInterval(() => {
|
|
582
|
+
try {
|
|
583
|
+
if (!still() || resolved || signal?.aborted)
|
|
584
|
+
end(null);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
end(null);
|
|
588
|
+
}
|
|
589
|
+
}, 500);
|
|
590
|
+
timer.unref?.();
|
|
591
|
+
poll.unref?.();
|
|
592
|
+
look();
|
|
593
|
+
});
|
|
594
|
+
/**
|
|
595
|
+
* Pairing: a free agent proposes one separable part of the task furthest
|
|
596
|
+
* behind (or, if nothing is cleanly separable, its tests); the author is
|
|
597
|
+
* told and has pairWaitMs to say no; then the part goes on the board for
|
|
598
|
+
* the helper. Merge order makes landing safe: a code part lands before the
|
|
599
|
+
* parent (the parent is written to use it, so its tests need it on main);
|
|
600
|
+
* a tests part lands after the parent (its tests run against the parent's
|
|
601
|
+
* code at the gate, so they verify something). Either way they're
|
|
602
|
+
* disjoint files, so neither order conflicts; it's about green gates.
|
|
603
|
+
*/
|
|
604
|
+
const pairUp = async (helper, author, h, t) => {
|
|
605
|
+
paired.add(t.id);
|
|
606
|
+
pairing.add(helper.id);
|
|
607
|
+
// Found live (round 4): 0 pairs, and nothing said whether one was ever tried. A dropped pairing says why.
|
|
608
|
+
const dropped = (reason) => {
|
|
609
|
+
try {
|
|
610
|
+
db.publish("pair.dropped", { task_id: t.id, agent_id: helper.id, payload: { with: author, title: t.title, reason } });
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* closed db */
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
try {
|
|
617
|
+
const still = () => running.get(author)?.taskId === t.id && (db.getTask(t.id)?.progress_pct ?? 0) < OFFER_MAX_PCT && !resolved && !signal?.aborted;
|
|
618
|
+
const wip = wipDiff(repoRoot, h.worktree, h.excludes, 12_000);
|
|
619
|
+
const files = JSON.parse(t.files ?? "[]");
|
|
620
|
+
let part = null;
|
|
621
|
+
if (helper.adapter?.ask) {
|
|
622
|
+
// The run waits for a pairing, so its ask ends as soon as the pairing stops mattering (the author finished, say).
|
|
623
|
+
const stop = new AbortController();
|
|
624
|
+
const off = () => stop.abort();
|
|
625
|
+
sideAsks.signal.addEventListener("abort", off);
|
|
626
|
+
const watch = setInterval(() => {
|
|
627
|
+
try {
|
|
628
|
+
if (!still())
|
|
629
|
+
stop.abort();
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
stop.abort();
|
|
633
|
+
}
|
|
634
|
+
}, 500);
|
|
635
|
+
watch.unref?.();
|
|
636
|
+
const res = await askAgent(helper.adapter, pairPrompt({ helper: helper.id, author, title: t.title, spec: t.spec, files, contract: contract(), diff: wip.diff }), repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => {
|
|
637
|
+
clearInterval(watch);
|
|
638
|
+
sideAsks.signal.removeEventListener("abort", off);
|
|
639
|
+
});
|
|
640
|
+
if (res.ok && !stop.signal.aborted)
|
|
641
|
+
part = parsePart(res.text, wip.files, files);
|
|
642
|
+
}
|
|
643
|
+
part ??= testsPart(t.title, t.spec);
|
|
644
|
+
if (!still())
|
|
645
|
+
return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished while ${helper.id} was proposing a part`);
|
|
646
|
+
const note = notifyAgent(db, author, pairNote(helper.id, part, pairWaitMs), "huddle", t.id);
|
|
647
|
+
const no = await waitForNo(author, helper.id, note.created_at, pairWaitMs, still);
|
|
648
|
+
if (no !== null) {
|
|
649
|
+
db.remember({ kind: "fact", body: `${author} kept "${t.title}" whole (declined ${helper.id} taking “${part.title}”): ${no}`.slice(0, 600), author: "huddle" });
|
|
650
|
+
return dropped(`${author} said no: ${no}`.slice(0, 300));
|
|
651
|
+
}
|
|
652
|
+
const parent = db.getTask(t.id);
|
|
653
|
+
if (!parent || !still() || !["claimed", "in_progress"].includes(parent.status))
|
|
654
|
+
return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished during the no-window`);
|
|
655
|
+
const pct = parent.progress_pct;
|
|
656
|
+
const secs = Math.round((Date.now() - h.startedAt) / 1000);
|
|
657
|
+
const spec = `${part.spec}\n\n${author} keeps the rest of “${t.title}” and was told not to edit ${part.files.length ? part.files.join(", ") : "your test file"}. ` +
|
|
658
|
+
`Match their names exactly; unsure? huddle_ask(…, to="${author}").` +
|
|
659
|
+
(wip.diff ? `\n\nTHEIR WORK SO FAR (when you were paired):\n${cap(wip.diff, 6000)}` : "");
|
|
660
|
+
const prefers = [`@${helper.id}`, ...JSON.parse(parent.prefers)];
|
|
661
|
+
const { task: pt } = addSplitPart(db, parent, author, { title: part.title, spec, files: part.files, prefers });
|
|
662
|
+
if (!pt)
|
|
663
|
+
return;
|
|
664
|
+
if (part.tests) {
|
|
665
|
+
holdFor.set(pt.id, parent.id);
|
|
666
|
+
testsOf.add(pt.id);
|
|
667
|
+
}
|
|
668
|
+
else
|
|
669
|
+
holdFor.set(parent.id, pt.id);
|
|
670
|
+
onEvent({
|
|
671
|
+
type: "pair",
|
|
672
|
+
agent: helper.id,
|
|
673
|
+
with: author,
|
|
674
|
+
task: parent.id,
|
|
675
|
+
title: parent.title,
|
|
676
|
+
part: part.tests ? `tests: ${pt.title}` : `${pt.title} (${part.files.join(", ")})`,
|
|
677
|
+
reason: `${parent.title} at ${pct}% after ${secs}s; ${helper.id} was free`,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
/* pairing never breaks a run */
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
pairing.delete(helper.id);
|
|
685
|
+
if (!resolved)
|
|
686
|
+
fill(); // the part (if any) goes to the helper now
|
|
386
687
|
}
|
|
387
688
|
};
|
|
388
|
-
/**
|
|
689
|
+
/**
|
|
690
|
+
* Every tick: a free agent with nothing to do pairs up with the task
|
|
691
|
+
* furthest behind. A helper that can't be asked (no read-only mode) only
|
|
692
|
+
* sends the old help offer, which leaves the split to the busy agent.
|
|
693
|
+
*/
|
|
389
694
|
const offerHelp = () => {
|
|
390
695
|
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
|
|
391
696
|
return;
|
|
392
|
-
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
697
|
+
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
393
698
|
if (!helper)
|
|
394
699
|
return;
|
|
700
|
+
if (helper.adapter.ask) {
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
const left = (t, h) => ((now - h.startedAt) * (100 - t.progress_pct)) / Math.max(t.progress_pct, 5);
|
|
703
|
+
// A task that never called huddle_progress isn't at 0%: it has no number (see pairEligible).
|
|
704
|
+
const reported = new Set(taskViews(db, now).filter((v) => v.reported).map((v) => v.id));
|
|
705
|
+
const best = [...running]
|
|
706
|
+
.map(([id, h]) => ({ id, h, t: db.getTask(h.taskId) }))
|
|
707
|
+
.filter((c) => !!c.t &&
|
|
708
|
+
!!c.h.child &&
|
|
709
|
+
c.id !== helper.id &&
|
|
710
|
+
c.t.claimed_by === c.id &&
|
|
711
|
+
["claimed", "in_progress"].includes(c.t.status) &&
|
|
712
|
+
!paired.has(c.t.id) &&
|
|
713
|
+
!resolving.has(c.t.id) &&
|
|
714
|
+
!testFixed.has(c.t.id) &&
|
|
715
|
+
!reviewed.has(c.t.id) &&
|
|
716
|
+
!splitFrom.has(c.t.id) && // a part isn't split again
|
|
717
|
+
!isSpecialTask(c.t) &&
|
|
718
|
+
now - c.h.startedAt >= helpAfterMs &&
|
|
719
|
+
pairEligible({
|
|
720
|
+
reported: reported.has(c.t.id),
|
|
721
|
+
pct: c.t.progress_pct,
|
|
722
|
+
ranMs: now - c.h.startedAt,
|
|
723
|
+
helpAfterMs,
|
|
724
|
+
planned: JSON.parse(c.t.files ?? "[]").length,
|
|
725
|
+
touched: () => wipDiff(repoRoot, c.h.worktree, c.h.excludes, 0).files.length,
|
|
726
|
+
}))
|
|
727
|
+
.sort((a, b) => left(b.t, b.h) - left(a.t, a.h))[0];
|
|
728
|
+
if (best)
|
|
729
|
+
void pairUp(helper, best.id, best.h, best.t);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
395
732
|
for (const [id, h] of running) {
|
|
396
733
|
if (id === helper.id || offered.has(h.taskId) || resolving.has(h.taskId) || Date.now() - h.startedAt < helpAfterMs)
|
|
397
734
|
continue;
|
|
@@ -404,7 +741,7 @@ export async function runBoard(opts) {
|
|
|
404
741
|
if ((t.progress_pct ?? 0) >= OFFER_MAX_PCT)
|
|
405
742
|
continue;
|
|
406
743
|
offered.add(t.id);
|
|
407
|
-
db
|
|
744
|
+
notifyAgent(db, id, helpOffer(helper.id, t.id, t.title), helper.id, t.id);
|
|
408
745
|
onEvent({ type: "offer", agent: helper.id, to: id, task: t.id, title: t.title });
|
|
409
746
|
}
|
|
410
747
|
};
|
|
@@ -418,6 +755,16 @@ export async function runBoard(opts) {
|
|
|
418
755
|
const by = db.getTask(e.task_id)?.claimed_by;
|
|
419
756
|
if (by && running.has(by))
|
|
420
757
|
lastSeen.set(by, Date.now());
|
|
758
|
+
let pct = 0;
|
|
759
|
+
try {
|
|
760
|
+
pct = Number(JSON.parse(e.payload || "{}").pct) || 0;
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
/* keep 0 */
|
|
764
|
+
}
|
|
765
|
+
const id = e.task_id;
|
|
766
|
+
if (pct >= BUDDY_AT[0])
|
|
767
|
+
setImmediate(() => void buddyCheck(id, pct).catch(() => { })); // never an unhandled rejection
|
|
421
768
|
}
|
|
422
769
|
else if (e.type === "task.split" && e.task_id) {
|
|
423
770
|
let p = {};
|
|
@@ -430,17 +777,68 @@ export async function runBoard(opts) {
|
|
|
430
777
|
splitFrom.set(e.task_id, { from: String(p.from ?? e.agent_id ?? ""), parentTitle: String(p.parent_title ?? "") });
|
|
431
778
|
setImmediate(() => !resolved && fill()); // a free agent may take it right away
|
|
432
779
|
}
|
|
780
|
+
else if (e.type === "note.closed" && e.task_id) {
|
|
781
|
+
// A note that needed an answer, settled at huddle_done (server.ts) or as its task finished.
|
|
782
|
+
let p = {};
|
|
783
|
+
try {
|
|
784
|
+
p = JSON.parse(e.payload || "{}");
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
/* keep {} */
|
|
788
|
+
}
|
|
789
|
+
const how = p.how === "replied" || p.how === "fixed" ? p.how : "ignored";
|
|
790
|
+
onEvent({ type: "followup", agent: String(p.to ?? e.agent_id ?? ""), by: String(p.by ?? ""), task: e.task_id, title: db.getTask(e.task_id)?.title ?? e.task_id, kind: p.kind === "radar" ? "radar" : "buddy", how, note: String(p.body ?? "") });
|
|
791
|
+
}
|
|
792
|
+
else if (e.type === "contract.amended") {
|
|
793
|
+
let p = {};
|
|
794
|
+
try {
|
|
795
|
+
p = JSON.parse(e.payload || "{}");
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
/* keep {} */
|
|
799
|
+
}
|
|
800
|
+
onEvent({ type: "contract", agent: String(p.by ?? e.agent_id ?? ""), version: Number(p.version) || 0, section: String(p.section ?? ""), change: String(p.change ?? "") });
|
|
801
|
+
}
|
|
433
802
|
});
|
|
434
803
|
const tick = setInterval(() => {
|
|
435
804
|
watchSilence();
|
|
436
805
|
offerHelp();
|
|
806
|
+
buddyBySilence();
|
|
437
807
|
}, Math.max(50, Math.min(10_000, Math.floor(silenceMs / 4))));
|
|
438
808
|
tick.unref();
|
|
439
809
|
stopWatch = () => {
|
|
440
810
|
clearInterval(tick);
|
|
441
811
|
unwatch();
|
|
442
812
|
};
|
|
813
|
+
// Integration radar: compare in-flight agents' uncommitted work for
|
|
814
|
+
// conflicts and name mismatches while they can still fix them (radar.ts).
|
|
815
|
+
// Local agents only — a teammate's work lives on their machine.
|
|
816
|
+
if (opts.radarMs !== null)
|
|
817
|
+
stopRadar = startRadar({
|
|
818
|
+
db,
|
|
819
|
+
repoRoot,
|
|
820
|
+
intervalMs: opts.radarMs,
|
|
821
|
+
inFlight: () => [...running.values()]
|
|
822
|
+
// Still working: an exited agent's worktree is being committed and rebased by finish().
|
|
823
|
+
.filter((h) => h.child && h.child.exitCode === null && h.child.signalCode === null && !resolving.has(h.taskId))
|
|
824
|
+
.map((h) => ({ agentId: h.agentId, taskId: h.taskId, title: db.getTask(h.taskId)?.title ?? h.taskId, worktree: h.worktree, excludes: taskWt.get(h.taskId)?.excludes ?? h.excludes })),
|
|
825
|
+
// A finding is a note its reader must answer (fix it, or say why not) before huddle_done.
|
|
826
|
+
notify: (to, message, key) => {
|
|
827
|
+
const h = running.get(to);
|
|
828
|
+
const m = notifyAgent(db, to, message, "huddle", h?.taskId ?? null);
|
|
829
|
+
if (h)
|
|
830
|
+
trackNote(db, m, { by: "huddle", kind: "radar", worktree: h.worktree, key });
|
|
831
|
+
},
|
|
832
|
+
// The radar no longer sees it: whoever fixed it, every note about it is answered.
|
|
833
|
+
resolved: (key) => resolveNotes(db, key),
|
|
834
|
+
contractNames: () => contractNames(getContract(db)),
|
|
835
|
+
onEvent,
|
|
836
|
+
});
|
|
443
837
|
}
|
|
838
|
+
/** Notes that came back after the author exited: one more round to answer them, before anything merges. */
|
|
839
|
+
const followUpBrief = (notes) => `\n\nWhile you were finishing, a teammate looked over your work and these notes reached Huddle after you exited — nothing is merged yet. ` +
|
|
840
|
+
`Address each one: fix it, or reply huddle_send(to="<who>", reply_to="<id>", message="won't fix: <why>"). Then huddle_done.\n` +
|
|
841
|
+
notes.map((n) => `- [id ${n.id}, from ${n.kind === "radar" ? "huddle" : n.by}] ${cap(n.body, 800)}`).join("\n");
|
|
444
842
|
const finish = async (w, h, exitCode, why) => {
|
|
445
843
|
const workMs = Date.now() - h.startedAt; // agent time, not test/merge/approval time
|
|
446
844
|
running.delete(w.id);
|
|
@@ -449,6 +847,19 @@ export async function runBoard(opts) {
|
|
|
449
847
|
const task = db.getTask(h.taskId);
|
|
450
848
|
const quiet = silenced.get(task.id);
|
|
451
849
|
silenced.delete(task.id);
|
|
850
|
+
// A buddy check still being written is about THIS work. Found live (capstone
|
|
851
|
+
// round 4): 3 of 4 buddy notes came back after the author finished, and
|
|
852
|
+
// reached its next task instead. Hold the author (and its merge) for it, bounded.
|
|
853
|
+
const check = buddyRuns.get(task.id);
|
|
854
|
+
if (check && exitCode === 0 && !signal?.aborted) {
|
|
855
|
+
reserved.add(w.id);
|
|
856
|
+
pending++; // the run isn't over while this waits
|
|
857
|
+
let timer;
|
|
858
|
+
await Promise.race([check.landed, new Promise((r) => (timer = setTimeout(r, Math.max(0, check.at + buddyWaitMs - Date.now()))))]);
|
|
859
|
+
clearTimeout(timer);
|
|
860
|
+
pending--;
|
|
861
|
+
reserved.delete(w.id);
|
|
862
|
+
}
|
|
452
863
|
if (signal?.aborted) {
|
|
453
864
|
fail(w, h, task.title, "stopped by you", workMs);
|
|
454
865
|
}
|
|
@@ -456,7 +867,26 @@ export async function runBoard(opts) {
|
|
|
456
867
|
failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
|
|
457
868
|
}
|
|
458
869
|
else {
|
|
870
|
+
// Notes it never saw (they came back after it exited). One it saw and didn't answer is just ignored.
|
|
871
|
+
const late = w.adapter ? openNotes(db, task.id).filter((n) => n.to === w.id && !db.getMessage(n.id)?.read_at) : [];
|
|
872
|
+
if (late.length && !followedUp.has(task.id)) {
|
|
873
|
+
followedUp.add(task.id);
|
|
874
|
+
for (const n of late)
|
|
875
|
+
db.markRead(n.id, "prompt"); // delivered in the brief, not the inbox
|
|
876
|
+
carry.set(task.id, totals(task.id, h, workMs));
|
|
877
|
+
db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) got ${late.length} teammate note(s) after it finished; one more round to answer them`, author: "huddle" });
|
|
878
|
+
start(w, task, followUpBrief(late));
|
|
879
|
+
return fill();
|
|
880
|
+
}
|
|
881
|
+
closeNotes(db, task.id); // each note: addressed or ignored (the note.closed watcher reports it)
|
|
459
882
|
db.setTaskStatus(task.id, "review");
|
|
883
|
+
// A paired task lands in a safe order (see pairUp): wait for its partner first.
|
|
884
|
+
const first = holdFor.get(task.id);
|
|
885
|
+
if (first && !settled(first)) {
|
|
886
|
+
held.push({ w, h, task, workMs, after: first });
|
|
887
|
+
pending++;
|
|
888
|
+
return fill();
|
|
889
|
+
}
|
|
460
890
|
return mergeTask(w, h, task, workMs);
|
|
461
891
|
}
|
|
462
892
|
fill();
|
|
@@ -489,6 +919,7 @@ export async function runBoard(opts) {
|
|
|
489
919
|
// Cross-review by an otherwise-idle agent: one round, never blocks on its own.
|
|
490
920
|
const reviewer = reviewed.has(task.id) ? undefined : idleReviewer(w.id);
|
|
491
921
|
let skippedReview = false;
|
|
922
|
+
let shallowReview = false;
|
|
492
923
|
if (reviewer) {
|
|
493
924
|
reviewed.add(task.id);
|
|
494
925
|
reviewsBy.set(reviewer.id, (reviewsBy.get(reviewer.id) ?? 0) + 1);
|
|
@@ -499,7 +930,7 @@ export async function runBoard(opts) {
|
|
|
499
930
|
onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
|
|
500
931
|
fill(); // everyone else keeps going
|
|
501
932
|
const t0 = Date.now();
|
|
502
|
-
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt)), wt.path, {
|
|
933
|
+
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
|
|
503
934
|
signal,
|
|
504
935
|
timeoutMs: 90_000,
|
|
505
936
|
noTools: true, // the diff is in the prompt — no need to explore
|
|
@@ -510,6 +941,7 @@ export async function runBoard(opts) {
|
|
|
510
941
|
db.setAgentStatus(reviewer.id, "idle");
|
|
511
942
|
pending--;
|
|
512
943
|
skippedReview = !!verdict.skipped;
|
|
944
|
+
shallowReview = !!verdict.shallow;
|
|
513
945
|
onEvent({ type: "reviewed", agent: reviewer.id, author: w.id, task: task.id, title: task.title, ...verdict, ms: Date.now() - t0 });
|
|
514
946
|
if (!verdict.approve && !signal?.aborted) {
|
|
515
947
|
db.remember({ kind: "gotcha", body: `Review of "${task.title}" by ${reviewer.id}: ${verdict.notes}`, author: reviewer.id });
|
|
@@ -553,7 +985,7 @@ export async function runBoard(opts) {
|
|
|
553
985
|
recordHandoff(db, repoRoot, { taskId: task.id, agentId: w.id, title: task.title, base, since: firstStart.get(task.id) ?? h.startedAt });
|
|
554
986
|
const resolvedBy = resolving.get(task.id)?.by.at(-1);
|
|
555
987
|
const reason = prep.reason +
|
|
556
|
-
(reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}` : "") +
|
|
988
|
+
(reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}${shallowReview ? " (shallow approve)" : ""}` : "") +
|
|
557
989
|
(resolvedBy ? ` · conflict resolved by ${resolvedBy}` : "");
|
|
558
990
|
onEvent({ type: "merged", agent: w.id, task: task.id, title: task.title, reason, ...totals(task.id, h, workMs) });
|
|
559
991
|
}
|
|
@@ -622,8 +1054,11 @@ export async function runBoard(opts) {
|
|
|
622
1054
|
db.publish("handoff", { task_id: task.id, agent_id: h.agent_id, payload: { from: h.agent_id, to: w.id, task: h.task_id, title: h.title, files: h.files, symbols: h.symbols } });
|
|
623
1055
|
onEvent({ type: "handoff", agent: w.id, from: h.agent_id, task: task.id, title: h.title, files: h.files.length, symbols: h.symbols.length });
|
|
624
1056
|
}
|
|
1057
|
+
// The contract as it stands now (amendments included): each launch is a fresh process.
|
|
1058
|
+
const cb = contractBrief(db);
|
|
1059
|
+
const contract = cb ? `\n\n${cb}` : "";
|
|
625
1060
|
if (w.remote)
|
|
626
|
-
return void startRemote(w, { ...task, spec: task.spec + ho.text }, extra);
|
|
1061
|
+
return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
|
|
627
1062
|
// Who else is mid-task right now, so this agent knows whom to tell (first round only;
|
|
628
1063
|
// fill() claims a whole wave before launching any of it, so siblings see each other).
|
|
629
1064
|
const along = first && !takeoverFrom ? alongsideBrief(db, task.id, w.id) : "";
|
|
@@ -635,7 +1070,7 @@ export async function runBoard(opts) {
|
|
|
635
1070
|
agentId: w.id,
|
|
636
1071
|
role: "worker",
|
|
637
1072
|
taskId: task.id,
|
|
638
|
-
taskSpec: `${task.title}\n\n${task.spec}${ho.text}${along}${extra}`,
|
|
1073
|
+
taskSpec: `${task.title}\n\n${task.spec}${contract}${ho.text}${along}${extra}`,
|
|
639
1074
|
roomUrl,
|
|
640
1075
|
roomSecret: opts.roomSecret,
|
|
641
1076
|
approvals: opts.approvals,
|
|
@@ -650,17 +1085,33 @@ export async function runBoard(opts) {
|
|
|
650
1085
|
running.set(w.id, h);
|
|
651
1086
|
heard(w.id, h);
|
|
652
1087
|
onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra && !takeoverFrom, ...(takeoverFrom ? { takeover: takeoverFrom } : {}) });
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
return;
|
|
657
|
-
exited = true;
|
|
658
|
-
void finish(w, h, code);
|
|
659
|
-
};
|
|
660
|
-
h.child.on("exit", once);
|
|
661
|
-
h.child.on("error", () => once(-1));
|
|
1088
|
+
// On 'close', capped at 2s after 'exit': found live, an agent's final stdout
|
|
1089
|
+
// lines (its token usage, the log's tail) were lost when finish() ran on 'exit'.
|
|
1090
|
+
onFinished(h.child, (code) => void finish(w, h, code));
|
|
662
1091
|
};
|
|
663
1092
|
const fill = () => {
|
|
1093
|
+
// Paired tasks whose partner has landed (or failed: then nothing to wait for) merge now.
|
|
1094
|
+
// Every ready one is taken out before any merge starts: a merge re-enters fill().
|
|
1095
|
+
const ready = held.filter((x) => signal?.aborted || settled(x.after));
|
|
1096
|
+
if (ready.length) {
|
|
1097
|
+
held.splice(0, held.length, ...held.filter((x) => !ready.includes(x)));
|
|
1098
|
+
let merging = 0;
|
|
1099
|
+
for (const x of ready) {
|
|
1100
|
+
pending--;
|
|
1101
|
+
holdFor.delete(x.task.id);
|
|
1102
|
+
if (signal?.aborted)
|
|
1103
|
+
fail(x.w, x.h, x.task.title, "stopped by you", x.workMs);
|
|
1104
|
+
// Tests for code that never landed would only go red at the gate and burn a fix round.
|
|
1105
|
+
else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
|
|
1106
|
+
fail(x.w, x.h, x.task.title, `the task it tests (“${db.getTask(x.after)?.title ?? x.after}”) didn't land — work kept on branch ${x.h.branch}`, x.workMs);
|
|
1107
|
+
else {
|
|
1108
|
+
merging++;
|
|
1109
|
+
void mergeTask(x.w, x.h, x.task, x.workMs);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (merging)
|
|
1113
|
+
return; // each merge fills again when it's done
|
|
1114
|
+
}
|
|
664
1115
|
// Conflicts first: they hold finished work, and the author is usually free.
|
|
665
1116
|
for (const c of conflicts.splice(0)) {
|
|
666
1117
|
if (signal?.aborted) {
|
|
@@ -730,7 +1181,15 @@ export async function runBoard(opts) {
|
|
|
730
1181
|
start(w, task);
|
|
731
1182
|
}
|
|
732
1183
|
offerHelp();
|
|
733
|
-
|
|
1184
|
+
// Nobody is working and only paired merges wait: their partner can't land now, so don't wait for it.
|
|
1185
|
+
if (running.size === 0 && held.length && pending === held.length && !pairing.size) {
|
|
1186
|
+
const x = held.shift();
|
|
1187
|
+
pending--;
|
|
1188
|
+
holdFor.delete(x.task.id);
|
|
1189
|
+
void mergeTask(x.w, x.h, x.task, x.workMs);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (running.size === 0 && pending === 0 && !pairing.size) {
|
|
734
1193
|
// Nobody is running yet a conflict still waits: no agent on this machine can resolve it.
|
|
735
1194
|
for (const c of conflicts.splice(0))
|
|
736
1195
|
parkForYou(c, "no agent on this machine could take the resolution");
|