huddle-cli 0.1.0 → 0.1.3
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 +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -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 +9 -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/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -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 +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -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 +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
// (dependencies must be merged first). When its process exits, the merge engine
|
|
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
|
+
import { userBrief } from "../room/user.js";
|
|
7
8
|
import { launchAgent, stopChild } from "./launcher.js";
|
|
8
|
-
import { prepareMerge, landMerge, removeWorktree, branchDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
|
|
9
|
+
import { prepareMerge, landMerge, removeWorktree, branchDiff, wipDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
|
|
9
10
|
import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
|
|
10
11
|
import { UsageMeter } from "../adapters/usage.js";
|
|
11
12
|
import { askAgent } from "./ask.js";
|
|
12
13
|
import { handoffsFor, headSha, recordHandoff } from "./handoff.js";
|
|
13
|
-
import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv } from "./takeover.js";
|
|
14
|
-
import { helpOffer, alongsideBrief } from "../room/progress.js";
|
|
14
|
+
import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv, pairWaitMsFromEnv, buddyAfterMsFromEnv, isSpecialTask, buddyPrompt, parseBuddy, pairPrompt, parsePart, testsPart, pairNote, BUDDY_AT, pairEligible, } from "./takeover.js";
|
|
15
|
+
import { helpOffer, alongsideBrief, addSplitPart, taskViews } from "../room/progress.js";
|
|
16
|
+
import { contractBrief, contractNames, getContract } from "../room/contract.js";
|
|
17
|
+
import { notifyAgent } from "../room/mail.js";
|
|
18
|
+
import { closeNotes, openNotes, resolveNotes, trackNote } from "../room/followup.js";
|
|
19
|
+
import { onFinished, processTable } from "../platform.js";
|
|
20
|
+
import { BusyWatch, silenceMaxMsFromEnv } from "./busy.js";
|
|
21
|
+
import { startRadar } from "./radar.js";
|
|
15
22
|
const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
|
|
16
23
|
/** No help offers on a task whose reported progress is this far along. */
|
|
17
24
|
export const OFFER_MAX_PCT = 60;
|
|
@@ -46,18 +53,20 @@ HOW:
|
|
|
46
53
|
resolution and re-runs the tests before anything lands.
|
|
47
54
|
4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
|
|
48
55
|
}
|
|
49
|
-
function reviewPrompt(title, spec, diff) {
|
|
56
|
+
function reviewPrompt(title, spec, diff, contract = "") {
|
|
50
57
|
return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
|
|
51
58
|
|
|
52
59
|
TASK: ${title}
|
|
53
60
|
${spec}
|
|
54
|
-
|
|
61
|
+
${contract ? `\n${cap(contract, 2500)}\n` : ""}
|
|
55
62
|
DIFF (against main):
|
|
56
63
|
${diff || "(empty diff)"}
|
|
57
64
|
|
|
58
65
|
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
|
|
66
|
+
Check, in order: (1) names and shapes match the ${contract ? "contract and the " : ""}task (functions, fields, routes, messages);
|
|
67
|
+
(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
68
|
Reply with the first line exactly APPROVE or CHANGES.
|
|
69
|
+
If APPROVE, add one line "Verified: <what you checked and found working>".
|
|
61
70
|
If CHANGES, add up to 6 short lines, each one concrete blocking problem and where it is.
|
|
62
71
|
Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you have. Do not edit anything.`;
|
|
63
72
|
}
|
|
@@ -65,14 +74,19 @@ Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you
|
|
|
65
74
|
* The first APPROVE/CHANGES line decides. Anything else is "skipped" — shown
|
|
66
75
|
* honestly, and it never blocks a merge (a reviewer can't block by accident).
|
|
67
76
|
*/
|
|
68
|
-
function parseVerdict(text) {
|
|
77
|
+
export function parseVerdict(text) {
|
|
69
78
|
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
70
|
-
|
|
79
|
+
// "APPROVED" is an approve too (it used to read as no verdict at all).
|
|
80
|
+
const i = lines.findIndex((l) => /^(APPROVED?|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
|
|
71
81
|
if (i < 0)
|
|
72
82
|
return { approve: true, skipped: true, notes: "no verdict" };
|
|
73
83
|
if (lines[i].replace(/[*`#_]/g, "").toUpperCase().startsWith("CHANGES"))
|
|
74
84
|
return { approve: false, notes: lines.slice(i + 1, i + 7).join("\n") || "(no details given)" };
|
|
75
|
-
|
|
85
|
+
// Found live: ~9s bare APPROVEs. An approve that says nothing about what was verified is shown as shallow.
|
|
86
|
+
const said = [lines[i].replace(/[*`#_]/g, "").replace(/^\s*APPROVE[D]?\b[\s:.,!—-]*/i, ""), ...lines.slice(i + 1, i + 3)].join(" ").trim();
|
|
87
|
+
// "Verified: ok" / "LGTM" say nothing either: what's left after the label has to name something.
|
|
88
|
+
const what = said.replace(/^(verified|checked)\s*:?\s*/i, "").replace(/^(lgtm|looks good|ok|fine)\b[\s.!,]*/i, "");
|
|
89
|
+
return what.length >= 10 ? { approve: true, notes: said } : { approve: true, shallow: true, notes: "approve (shallow)" };
|
|
76
90
|
}
|
|
77
91
|
export async function runBoard(opts) {
|
|
78
92
|
const { db, repoRoot, roomUrl, workers, testCmd, signal } = opts;
|
|
@@ -90,6 +104,16 @@ export async function runBoard(opts) {
|
|
|
90
104
|
}
|
|
91
105
|
opts.onEvent(e);
|
|
92
106
|
};
|
|
107
|
+
// The shared contract, when the Council wrote one ("" when there is none).
|
|
108
|
+
const contractOf = opts.contractBrief ?? contractBrief;
|
|
109
|
+
const contract = () => {
|
|
110
|
+
try {
|
|
111
|
+
return contractOf(db) || "";
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
93
117
|
const running = new Map(); // agent id -> handle
|
|
94
118
|
let merged = 0;
|
|
95
119
|
let failed = 0;
|
|
@@ -112,11 +136,17 @@ export async function runBoard(opts) {
|
|
|
112
136
|
return new Promise((resolve) => {
|
|
113
137
|
let resolved = false;
|
|
114
138
|
let stopWatch = () => { }; // the silence watchdog + help offers (set up below)
|
|
139
|
+
let stopRadar = () => { }; // the integration radar (set up below)
|
|
140
|
+
// Buddy checks and pairing asks end with the run (or with you): no ask outlives it.
|
|
141
|
+
const sideAsks = new AbortController();
|
|
142
|
+
signal?.addEventListener("abort", () => sideAsks.abort());
|
|
115
143
|
const done = () => {
|
|
116
144
|
if (resolved)
|
|
117
145
|
return;
|
|
118
146
|
resolved = true;
|
|
147
|
+
sideAsks.abort();
|
|
119
148
|
stopWatch();
|
|
149
|
+
stopRadar();
|
|
120
150
|
process.off("exit", killAll);
|
|
121
151
|
resolve({ merged, failed, stopped: !!signal?.aborted });
|
|
122
152
|
};
|
|
@@ -127,6 +157,10 @@ export async function runBoard(opts) {
|
|
|
127
157
|
signal?.addEventListener("abort", killAll);
|
|
128
158
|
// If Huddle itself exits, never leave orphaned agents working for nobody.
|
|
129
159
|
process.once("exit", killAll);
|
|
160
|
+
// The token budget: past its soft line nothing new starts (tasks, takeovers, buddy checks, pairings);
|
|
161
|
+
// what's running finishes. Its hard line aborts the run's signal (the caller wires that, as Ctrl-C does).
|
|
162
|
+
const overBudget = () => !!opts.budget?.stopping;
|
|
163
|
+
const stoppedWhy = () => (opts.budget?.level === "hard" ? `stopped: the token budget ran over (${Math.round(opts.budget.used * 100)}%)` : "stopped by you");
|
|
130
164
|
// Agents held out of fill(): an author waiting on review, a reviewer reviewing.
|
|
131
165
|
const reserved = new Set();
|
|
132
166
|
const reviewed = new Set(); // task ids that already had their one review round
|
|
@@ -233,27 +267,22 @@ export async function runBoard(opts) {
|
|
|
233
267
|
agentId: r.id,
|
|
234
268
|
role: "worker",
|
|
235
269
|
taskId: task.id,
|
|
236
|
-
|
|
270
|
+
// The contract decides a name clash: both sides code to it.
|
|
271
|
+
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
272
|
roomUrl,
|
|
238
273
|
roomSecret: opts.roomSecret,
|
|
239
274
|
approvals: opts.approvals,
|
|
240
275
|
worktree: wt,
|
|
241
276
|
onTokens: (tokens) => onEvent({ type: "tokens", agent: r.id, task: task.id, tokens }),
|
|
242
277
|
});
|
|
278
|
+
opts.budget?.track(h2.meter, r.id);
|
|
243
279
|
db.setTaskStatus(task.id, "in_progress");
|
|
244
280
|
db.setAgentStatus(r.id, "working", wt.path);
|
|
245
281
|
running.set(r.id, h2);
|
|
246
282
|
heard(r.id, h2);
|
|
247
283
|
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));
|
|
284
|
+
// On 'close' (capped): its last stdout lines (token usage) can still be in the pipe at 'exit'.
|
|
285
|
+
onFinished(h2.child, (code) => void endResolve(r, c, h2, cs, code));
|
|
257
286
|
};
|
|
258
287
|
const endResolve = async (r, c, h2, cs, code) => {
|
|
259
288
|
const ms = Date.now() - h2.startedAt;
|
|
@@ -266,7 +295,7 @@ export async function runBoard(opts) {
|
|
|
266
295
|
// Both agents' Plug wiring may sit in this worktree; neither may be committed.
|
|
267
296
|
const h = { ...h2, excludes: [...new Set([...c.h.excludes, ...h2.excludes])] };
|
|
268
297
|
const res = signal?.aborted
|
|
269
|
-
? { ok: false, reason:
|
|
298
|
+
? { ok: false, reason: stoppedWhy() }
|
|
270
299
|
: code === 0
|
|
271
300
|
? finishConflictMerge(wt, cs, h.excludes)
|
|
272
301
|
: { ok: false, reason: `resolver exited with code ${code}` };
|
|
@@ -293,10 +322,17 @@ export async function runBoard(opts) {
|
|
|
293
322
|
const taskWt = new Map(); // task id -> its worktree, whoever works it
|
|
294
323
|
const lastSeen = new Map(); // agent id -> its last output or huddle_* call
|
|
295
324
|
const silenced = new Map(); // task id -> why we stopped its agent
|
|
325
|
+
// Quiet isn't stuck: a long test run prints nothing (busy.ts). Busy restarts the silence clock, up to a cap.
|
|
326
|
+
const silenceMaxMs = Math.max(silenceMs, opts.silenceMaxMs ?? silenceMaxMsFromEnv(silenceMs));
|
|
327
|
+
const busyWatch = new BusyWatch();
|
|
328
|
+
const lastBusy = new Map(); // agent id -> the last look that found it working
|
|
329
|
+
const busyTold = new Set(); // launches whose "busy, not silent" was reported
|
|
296
330
|
/** Start listening for signs of life from a freshly launched agent. */
|
|
297
331
|
const heard = (id, h) => {
|
|
298
332
|
const bump = () => lastSeen.set(id, Date.now());
|
|
299
333
|
bump();
|
|
334
|
+
lastBusy.delete(id);
|
|
335
|
+
busyWatch.forget(id);
|
|
300
336
|
h.child?.stdout?.on("data", bump);
|
|
301
337
|
h.child?.stderr?.on("data", bump);
|
|
302
338
|
};
|
|
@@ -370,28 +406,383 @@ export async function runBoard(opts) {
|
|
|
370
406
|
});
|
|
371
407
|
start(r, task, brief, rc.from.id);
|
|
372
408
|
};
|
|
373
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Stop agents that have gone quiet; they're taken over in finish(). Quiet =
|
|
411
|
+
* no output and no huddle_* call. Past half the window Huddle starts looking
|
|
412
|
+
* at what the agent is doing (busy.ts); a look that finds it working counts
|
|
413
|
+
* as a sign of life, so only an agent quiet AND idle for the whole window is
|
|
414
|
+
* silent — or one quiet past the cap, working or not.
|
|
415
|
+
*/
|
|
416
|
+
const span = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.round(ms / 1000)}s`);
|
|
374
417
|
const watchSilence = () => {
|
|
418
|
+
let table; // one process snapshot per tick, only when someone is quiet
|
|
419
|
+
const now = Date.now();
|
|
375
420
|
for (const [id, h] of running) {
|
|
376
421
|
if (!h.child || silenced.has(h.taskId) || h.child.exitCode !== null || h.child.signalCode !== null)
|
|
377
422
|
continue;
|
|
378
|
-
const quiet =
|
|
379
|
-
if (quiet < silenceMs)
|
|
423
|
+
const quiet = now - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
|
|
424
|
+
if (quiet < silenceMs / 2) {
|
|
425
|
+
busyWatch.forget(id); // it spoke: the next quiet spell compares from a fresh look
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
let firstLook = false;
|
|
429
|
+
if (quiet < silenceMaxMs && h.child.pid) {
|
|
430
|
+
if (table === undefined) {
|
|
431
|
+
try {
|
|
432
|
+
table = (opts.processTable ?? processTable)();
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
table = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const v = busyWatch.look(id, { pid: h.child.pid, worktree: h.worktree, logPath: h.logPath }, table, now);
|
|
439
|
+
firstLook = v === null; // nothing to compare with yet: decide on the next tick
|
|
440
|
+
if (v?.busy) {
|
|
441
|
+
lastBusy.set(id, now);
|
|
442
|
+
if (quiet >= silenceMs && !busyTold.has(h)) {
|
|
443
|
+
busyTold.add(h);
|
|
444
|
+
const t = db.getTask(h.taskId);
|
|
445
|
+
onEvent({ type: "busy", agent: id, task: h.taskId, title: t?.title ?? h.taskId, quietMs: quiet, why: v.why });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const idle = now - Math.max(lastSeen.get(id) ?? 0, lastBusy.get(id) ?? 0, h.startedAt);
|
|
450
|
+
if (quiet >= silenceMaxMs)
|
|
451
|
+
silenced.set(h.taskId, busyTold.has(h)
|
|
452
|
+
? `went silent — no output and no huddle_* call for ${span(quiet)}; it looked busy (a long command?), but that is past the ${span(silenceMaxMs)} cap`
|
|
453
|
+
: `went silent — no output and no huddle_* call for ${span(quiet)}`);
|
|
454
|
+
else if (idle >= silenceMs && !firstLook)
|
|
455
|
+
silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span(quiet)}, and nothing running (no child using CPU, no files changing)`);
|
|
456
|
+
else
|
|
457
|
+
continue;
|
|
458
|
+
busyWatch.forget(id);
|
|
459
|
+
stopChild(h.child);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
// --- teamwork mid-task --------------------------------------------------
|
|
463
|
+
// Found live (3 capstone runs): every help offer was declined, no split
|
|
464
|
+
// ever happened, and reviews came only after huddle_done. So Huddle acts:
|
|
465
|
+
// buddy checks put a second agent's eyes on work in progress, and pairing
|
|
466
|
+
// hands a part of the slowest task to a free agent unless its author says no.
|
|
467
|
+
const buddyOn = opts.buddyChecks ?? !/^(off|no|0|false)$/i.test(process.env.HUDDLE_BUDDY ?? "");
|
|
468
|
+
const pairWaitMs = opts.pairWaitMs ?? pairWaitMsFromEnv();
|
|
469
|
+
const buddyAfterMs = opts.buddyAfterMs !== undefined ? opts.buddyAfterMs : buddyAfterMsFromEnv();
|
|
470
|
+
const buddyBy = new Map(); // agent id -> buddy checks it gave
|
|
471
|
+
const buddyDone = new Map(); // task id -> its checks so far (and the first one's pct)
|
|
472
|
+
const buddyBusy = new Set(); // task ids with a check in flight
|
|
473
|
+
const buddyRuns = new Map(); // task id -> its check in flight, for finish() to wait on
|
|
474
|
+
const buddyWaitMs = opts.buddyWaitMs ?? 60_000;
|
|
475
|
+
const followedUp = new Set(); // task ids that had their one round for notes that came too late
|
|
476
|
+
const paired = new Set(); // task ids that had their one pairing (or attempt)
|
|
477
|
+
const pairing = new Set(); // helper ids mid-pairing (asking, or waiting for a no)
|
|
478
|
+
const holdFor = new Map(); // task id -> the partner task that must land first
|
|
479
|
+
const testsOf = new Set(); // tests-part task ids (they test their partner's code)
|
|
480
|
+
const held = []; // finished, waiting for their partner to land
|
|
481
|
+
const settled = (id) => {
|
|
482
|
+
const st = db.getTask(id)?.status;
|
|
483
|
+
return !st || st === "merged" || st === "failed" || st === "needs_you";
|
|
484
|
+
};
|
|
485
|
+
/** The other tasks in flight, with their work so far (capped): where a buddy spots XYZ vs ABC. */
|
|
486
|
+
const othersInFlight = (taskId) => [...running.values()]
|
|
487
|
+
.filter((x) => x.taskId !== taskId && x.child)
|
|
488
|
+
.slice(0, 3)
|
|
489
|
+
.map((x) => {
|
|
490
|
+
const t = db.getTask(x.taskId);
|
|
491
|
+
const wip = wipDiff(repoRoot, x.worktree, x.excludes, 2500);
|
|
492
|
+
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}` : ""}`;
|
|
493
|
+
})
|
|
494
|
+
.join("\n");
|
|
495
|
+
/** Never the author; a free agent first, then the one that gave the fewest buddy checks. */
|
|
496
|
+
const pickBuddy = (authorId) => workers
|
|
497
|
+
.filter((x) => x.id !== authorId && x.adapter?.ask)
|
|
498
|
+
.map((w, i) => ({ w, i, busy: running.has(w.id) || reserved.has(w.id) ? 1 : 0 }))
|
|
499
|
+
.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;
|
|
500
|
+
/** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
|
|
501
|
+
const buddyCheck = async (taskId, pct, silentMs = 0) => {
|
|
502
|
+
if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId) || overBudget())
|
|
503
|
+
return;
|
|
504
|
+
const t = db.getTask(taskId);
|
|
505
|
+
if (!t?.claimed_by || isSpecialTask(t))
|
|
506
|
+
return;
|
|
507
|
+
const author = t.claimed_by;
|
|
508
|
+
const h = running.get(author);
|
|
509
|
+
if (!h || h.taskId !== taskId || !h.child)
|
|
510
|
+
return; // local, and mid-task
|
|
511
|
+
const st = buddyDone.get(taskId) ?? { n: 0, notes: false, at: 0 };
|
|
512
|
+
// The second look is a follow-up on the first one's notes; a first look at 80% already was the late one.
|
|
513
|
+
// silentMs: the author never reported progress (found live: Claude called
|
|
514
|
+
// huddle_progress twice in 4 tasks), so the first look goes by time instead.
|
|
515
|
+
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];
|
|
516
|
+
if (!due)
|
|
517
|
+
return;
|
|
518
|
+
const buddy = pickBuddy(author);
|
|
519
|
+
if (!buddy)
|
|
520
|
+
return;
|
|
521
|
+
// Nothing written yet: nothing to look at, and not worth a teammate's tokens. The next report tries again.
|
|
522
|
+
const wip = wipDiff(repoRoot, h.worktree, h.excludes, 16_000);
|
|
523
|
+
if (!wip.diff)
|
|
524
|
+
return;
|
|
525
|
+
st.n++;
|
|
526
|
+
if (st.n === 1)
|
|
527
|
+
st.at = pct;
|
|
528
|
+
buddyDone.set(taskId, st);
|
|
529
|
+
buddyBusy.add(taskId);
|
|
530
|
+
buddyBy.set(buddy.id, (buddyBy.get(buddy.id) ?? 0) + 1);
|
|
531
|
+
const t0 = Date.now();
|
|
532
|
+
let landed = () => { };
|
|
533
|
+
buddyRuns.set(taskId, { landed: new Promise((r) => (landed = r)), at: t0 });
|
|
534
|
+
try {
|
|
535
|
+
// The Room's huddle_done waits on this pair (server.ts), so the notes reach the author before it exits.
|
|
536
|
+
db.publish("buddy.start", { task_id: taskId, agent_id: buddy.id, payload: { author, pct } });
|
|
537
|
+
const when = silentMs ? `${Math.round(silentMs / 60_000) || 1} min in` : `${pct}%`;
|
|
538
|
+
const prompt = buddyPrompt({ author, when, title: t.title, spec: t.spec, contract: contract(), others: othersInFlight(taskId), diff: wip.diff });
|
|
539
|
+
// Its own signal, tied to the run's: askAgent never unhooks, and a long run would pile listeners on one signal.
|
|
540
|
+
const stop = new AbortController();
|
|
541
|
+
const off = () => stop.abort();
|
|
542
|
+
sideAsks.signal.addEventListener("abort", off);
|
|
543
|
+
const res = await askAgent(buddy.adapter, prompt, repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => sideAsks.signal.removeEventListener("abort", off));
|
|
544
|
+
if (resolved || !res.ok)
|
|
545
|
+
return; // no answer: the author never knew it was asked
|
|
546
|
+
const notes = parseBuddy(res.text);
|
|
547
|
+
st.notes = !!notes;
|
|
548
|
+
// Its author is past it (finish() waited buddyWaitMs; it's merging or landed): the notes would only reach the
|
|
549
|
+
// author's NEXT task, where they mean nothing. Shared memory keeps them for QA and later tasks.
|
|
550
|
+
const st2 = db.getTask(taskId)?.status;
|
|
551
|
+
const late = !!notes && !!st2 && !["claimed", "in_progress"].includes(st2);
|
|
552
|
+
if (notes && late)
|
|
553
|
+
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 });
|
|
554
|
+
else if (notes) {
|
|
555
|
+
const m = notifyAgent(db, author, `buddy check from ${buddy.id} ${silentMs ? when : `at ${when}`}:\n${notes}`, "huddle", taskId);
|
|
556
|
+
trackNote(db, m, { by: buddy.id, kind: "buddy", worktree: h.worktree });
|
|
557
|
+
}
|
|
558
|
+
onEvent({ type: "checkpoint", agent: buddy.id, author, task: taskId, title: t.title, pct, notes: notes ?? "LOOKS GOOD", ms: Date.now() - t0, ...(late ? { late } : {}) });
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
/* a buddy check never breaks a run */
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
buddyBusy.delete(taskId);
|
|
565
|
+
buddyRuns.delete(taskId);
|
|
566
|
+
try {
|
|
567
|
+
db.publish("buddy.end", { task_id: taskId, agent_id: buddy.id });
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* closed db */
|
|
571
|
+
}
|
|
572
|
+
landed();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
/** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
|
|
576
|
+
const buddyBySilence = () => {
|
|
577
|
+
if (buddyAfterMs === null || !buddyOn || overBudget())
|
|
578
|
+
return;
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
for (const h of running.values()) {
|
|
581
|
+
if (!h.child || buddyDone.has(h.taskId) || buddyBusy.has(h.taskId) || now - h.startedAt < buddyAfterMs)
|
|
582
|
+
continue;
|
|
583
|
+
// Reached 40%: that report triggers it. One early "10%" and then quiet still counts as quiet.
|
|
584
|
+
const pct = db.getTask(h.taskId)?.progress_pct ?? 0;
|
|
585
|
+
if (pct >= BUDDY_AT[0])
|
|
380
586
|
continue;
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
587
|
+
void buddyCheck(h.taskId, pct, now - h.startedAt).catch(() => { });
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
/** Resolves with the author's "no: …" reason, or null when the window closes (or it says yes, or stops mattering). */
|
|
591
|
+
const waitForNo = (author, helper, since, ms, still) => new Promise((resolve) => {
|
|
592
|
+
let over = false;
|
|
593
|
+
const end = (v) => {
|
|
594
|
+
if (over)
|
|
595
|
+
return;
|
|
596
|
+
over = true;
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
clearInterval(poll);
|
|
599
|
+
unsub();
|
|
600
|
+
resolve(v);
|
|
601
|
+
};
|
|
602
|
+
const look = () => {
|
|
603
|
+
if (over)
|
|
604
|
+
return;
|
|
605
|
+
for (const m of db.inbox("huddle", { peek: true })) {
|
|
606
|
+
if (m.from_agent !== author || m.created_at < since)
|
|
607
|
+
continue;
|
|
608
|
+
db.markRead(m.id);
|
|
609
|
+
const body = m.body.replace(/[*`_]/g, "").trim();
|
|
610
|
+
if (/^(yes|y|ok|okay|go|sure|fine|sounds good|go ahead)\b/i.test(body))
|
|
611
|
+
return end(null);
|
|
612
|
+
// Anything else the author sends Huddle in the window is an objection: it was asked for a no, not a chat.
|
|
613
|
+
return end(body.replace(/^(no|nope|nah|don't|do not)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
|
|
614
|
+
}
|
|
615
|
+
// Models mix up recipients: a "no" sent to the helper itself counts too (left unread for the helper).
|
|
616
|
+
for (const m of db.inbox(helper, { peek: true })) {
|
|
617
|
+
if (m.from_agent !== author || m.created_at < since)
|
|
618
|
+
continue;
|
|
619
|
+
const body = m.body.replace(/[*`_]/g, "").trim();
|
|
620
|
+
if (/^(no|nope|nah)\b/i.test(body))
|
|
621
|
+
return end(body.replace(/^(no|nope|nah)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const unsub = db.onEvent((e) => {
|
|
625
|
+
if (e.type !== "agent.message")
|
|
626
|
+
return;
|
|
627
|
+
try {
|
|
628
|
+
look();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
/* a read that fails is just no reply yet */
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
const timer = setTimeout(() => end(null), ms);
|
|
635
|
+
const poll = setInterval(() => {
|
|
636
|
+
try {
|
|
637
|
+
if (!still() || resolved || signal?.aborted)
|
|
638
|
+
end(null);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
end(null);
|
|
642
|
+
}
|
|
643
|
+
}, 500);
|
|
644
|
+
timer.unref?.();
|
|
645
|
+
poll.unref?.();
|
|
646
|
+
look();
|
|
647
|
+
});
|
|
648
|
+
/**
|
|
649
|
+
* Pairing: a free agent proposes one separable part of the task furthest
|
|
650
|
+
* behind (or, if nothing is cleanly separable, its tests); the author is
|
|
651
|
+
* told and has pairWaitMs to say no; then the part goes on the board for
|
|
652
|
+
* the helper. Merge order makes landing safe: a code part lands before the
|
|
653
|
+
* parent (the parent is written to use it, so its tests need it on main);
|
|
654
|
+
* a tests part lands after the parent (its tests run against the parent's
|
|
655
|
+
* code at the gate, so they verify something). Either way they're
|
|
656
|
+
* disjoint files, so neither order conflicts; it's about green gates.
|
|
657
|
+
*/
|
|
658
|
+
const pairUp = async (helper, author, h, t) => {
|
|
659
|
+
paired.add(t.id);
|
|
660
|
+
pairing.add(helper.id);
|
|
661
|
+
// Found live (round 4): 0 pairs, and nothing said whether one was ever tried. A dropped pairing says why.
|
|
662
|
+
const dropped = (reason) => {
|
|
663
|
+
try {
|
|
664
|
+
db.publish("pair.dropped", { task_id: t.id, agent_id: helper.id, payload: { with: author, title: t.title, reason } });
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
/* closed db */
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
try {
|
|
671
|
+
const still = () => running.get(author)?.taskId === t.id && (db.getTask(t.id)?.progress_pct ?? 0) < OFFER_MAX_PCT && !resolved && !signal?.aborted;
|
|
672
|
+
const wip = wipDiff(repoRoot, h.worktree, h.excludes, 12_000);
|
|
673
|
+
const files = JSON.parse(t.files ?? "[]");
|
|
674
|
+
let part = null;
|
|
675
|
+
if (helper.adapter?.ask) {
|
|
676
|
+
// The run waits for a pairing, so its ask ends as soon as the pairing stops mattering (the author finished, say).
|
|
677
|
+
const stop = new AbortController();
|
|
678
|
+
const off = () => stop.abort();
|
|
679
|
+
sideAsks.signal.addEventListener("abort", off);
|
|
680
|
+
const watch = setInterval(() => {
|
|
681
|
+
try {
|
|
682
|
+
if (!still())
|
|
683
|
+
stop.abort();
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
stop.abort();
|
|
687
|
+
}
|
|
688
|
+
}, 500);
|
|
689
|
+
watch.unref?.();
|
|
690
|
+
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(() => {
|
|
691
|
+
clearInterval(watch);
|
|
692
|
+
sideAsks.signal.removeEventListener("abort", off);
|
|
693
|
+
});
|
|
694
|
+
if (res.ok && !stop.signal.aborted)
|
|
695
|
+
part = parsePart(res.text, wip.files, files);
|
|
696
|
+
}
|
|
697
|
+
part ??= testsPart(t.title, t.spec);
|
|
698
|
+
if (!still())
|
|
699
|
+
return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished while ${helper.id} was proposing a part`);
|
|
700
|
+
const note = notifyAgent(db, author, pairNote(helper.id, part, pairWaitMs), "huddle", t.id);
|
|
701
|
+
const no = await waitForNo(author, helper.id, note.created_at, pairWaitMs, still);
|
|
702
|
+
if (no !== null) {
|
|
703
|
+
db.remember({ kind: "fact", body: `${author} kept "${t.title}" whole (declined ${helper.id} taking “${part.title}”): ${no}`.slice(0, 600), author: "huddle" });
|
|
704
|
+
return dropped(`${author} said no: ${no}`.slice(0, 300));
|
|
705
|
+
}
|
|
706
|
+
const parent = db.getTask(t.id);
|
|
707
|
+
if (!parent || !still() || !["claimed", "in_progress"].includes(parent.status))
|
|
708
|
+
return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished during the no-window`);
|
|
709
|
+
const pct = parent.progress_pct;
|
|
710
|
+
const secs = Math.round((Date.now() - h.startedAt) / 1000);
|
|
711
|
+
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"}. ` +
|
|
712
|
+
`Match their names exactly; unsure? huddle_ask(…, to="${author}").` +
|
|
713
|
+
(wip.diff ? `\n\nTHEIR WORK SO FAR (when you were paired):\n${cap(wip.diff, 6000)}` : "");
|
|
714
|
+
const prefers = [`@${helper.id}`, ...JSON.parse(parent.prefers)];
|
|
715
|
+
const { task: pt } = addSplitPart(db, parent, author, { title: part.title, spec, files: part.files, prefers });
|
|
716
|
+
if (!pt)
|
|
717
|
+
return;
|
|
718
|
+
if (part.tests) {
|
|
719
|
+
holdFor.set(pt.id, parent.id);
|
|
720
|
+
testsOf.add(pt.id);
|
|
721
|
+
}
|
|
722
|
+
else
|
|
723
|
+
holdFor.set(parent.id, pt.id);
|
|
724
|
+
onEvent({
|
|
725
|
+
type: "pair",
|
|
726
|
+
agent: helper.id,
|
|
727
|
+
with: author,
|
|
728
|
+
task: parent.id,
|
|
729
|
+
title: parent.title,
|
|
730
|
+
part: part.tests ? `tests: ${pt.title}` : `${pt.title} (${part.files.join(", ")})`,
|
|
731
|
+
reason: `${parent.title} at ${pct}% after ${secs}s; ${helper.id} was free`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
/* pairing never breaks a run */
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
pairing.delete(helper.id);
|
|
739
|
+
if (!resolved)
|
|
740
|
+
fill(); // the part (if any) goes to the helper now
|
|
386
741
|
}
|
|
387
742
|
};
|
|
388
|
-
/**
|
|
743
|
+
/**
|
|
744
|
+
* Every tick: a free agent with nothing to do pairs up with the task
|
|
745
|
+
* furthest behind. A helper that can't be asked (no read-only mode) only
|
|
746
|
+
* sends the old help offer, which leaves the split to the busy agent.
|
|
747
|
+
*/
|
|
389
748
|
const offerHelp = () => {
|
|
390
|
-
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
|
|
749
|
+
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length || overBudget())
|
|
391
750
|
return;
|
|
392
|
-
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
751
|
+
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
393
752
|
if (!helper)
|
|
394
753
|
return;
|
|
754
|
+
if (helper.adapter.ask) {
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
const left = (t, h) => ((now - h.startedAt) * (100 - t.progress_pct)) / Math.max(t.progress_pct, 5);
|
|
757
|
+
// A task that never called huddle_progress isn't at 0%: it has no number (see pairEligible).
|
|
758
|
+
const reported = new Set(taskViews(db, now).filter((v) => v.reported).map((v) => v.id));
|
|
759
|
+
const best = [...running]
|
|
760
|
+
.map(([id, h]) => ({ id, h, t: db.getTask(h.taskId) }))
|
|
761
|
+
.filter((c) => !!c.t &&
|
|
762
|
+
!!c.h.child &&
|
|
763
|
+
c.id !== helper.id &&
|
|
764
|
+
c.t.claimed_by === c.id &&
|
|
765
|
+
["claimed", "in_progress"].includes(c.t.status) &&
|
|
766
|
+
!paired.has(c.t.id) &&
|
|
767
|
+
!resolving.has(c.t.id) &&
|
|
768
|
+
!testFixed.has(c.t.id) &&
|
|
769
|
+
!reviewed.has(c.t.id) &&
|
|
770
|
+
!splitFrom.has(c.t.id) && // a part isn't split again
|
|
771
|
+
!isSpecialTask(c.t) &&
|
|
772
|
+
now - c.h.startedAt >= helpAfterMs &&
|
|
773
|
+
pairEligible({
|
|
774
|
+
reported: reported.has(c.t.id),
|
|
775
|
+
pct: c.t.progress_pct,
|
|
776
|
+
ranMs: now - c.h.startedAt,
|
|
777
|
+
helpAfterMs,
|
|
778
|
+
planned: JSON.parse(c.t.files ?? "[]").length,
|
|
779
|
+
touched: () => wipDiff(repoRoot, c.h.worktree, c.h.excludes, 0).files.length,
|
|
780
|
+
}))
|
|
781
|
+
.sort((a, b) => left(b.t, b.h) - left(a.t, a.h))[0];
|
|
782
|
+
if (best)
|
|
783
|
+
void pairUp(helper, best.id, best.h, best.t);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
395
786
|
for (const [id, h] of running) {
|
|
396
787
|
if (id === helper.id || offered.has(h.taskId) || resolving.has(h.taskId) || Date.now() - h.startedAt < helpAfterMs)
|
|
397
788
|
continue;
|
|
@@ -404,7 +795,7 @@ export async function runBoard(opts) {
|
|
|
404
795
|
if ((t.progress_pct ?? 0) >= OFFER_MAX_PCT)
|
|
405
796
|
continue;
|
|
406
797
|
offered.add(t.id);
|
|
407
|
-
db
|
|
798
|
+
notifyAgent(db, id, helpOffer(helper.id, t.id, t.title), helper.id, t.id);
|
|
408
799
|
onEvent({ type: "offer", agent: helper.id, to: id, task: t.id, title: t.title });
|
|
409
800
|
}
|
|
410
801
|
};
|
|
@@ -418,6 +809,16 @@ export async function runBoard(opts) {
|
|
|
418
809
|
const by = db.getTask(e.task_id)?.claimed_by;
|
|
419
810
|
if (by && running.has(by))
|
|
420
811
|
lastSeen.set(by, Date.now());
|
|
812
|
+
let pct = 0;
|
|
813
|
+
try {
|
|
814
|
+
pct = Number(JSON.parse(e.payload || "{}").pct) || 0;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
/* keep 0 */
|
|
818
|
+
}
|
|
819
|
+
const id = e.task_id;
|
|
820
|
+
if (pct >= BUDDY_AT[0])
|
|
821
|
+
setImmediate(() => void buddyCheck(id, pct).catch(() => { })); // never an unhandled rejection
|
|
421
822
|
}
|
|
422
823
|
else if (e.type === "task.split" && e.task_id) {
|
|
423
824
|
let p = {};
|
|
@@ -430,17 +831,69 @@ export async function runBoard(opts) {
|
|
|
430
831
|
splitFrom.set(e.task_id, { from: String(p.from ?? e.agent_id ?? ""), parentTitle: String(p.parent_title ?? "") });
|
|
431
832
|
setImmediate(() => !resolved && fill()); // a free agent may take it right away
|
|
432
833
|
}
|
|
834
|
+
else if (e.type === "note.closed" && e.task_id) {
|
|
835
|
+
// A note that needed an answer, settled at huddle_done (server.ts) or as its task finished.
|
|
836
|
+
let p = {};
|
|
837
|
+
try {
|
|
838
|
+
p = JSON.parse(e.payload || "{}");
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
/* keep {} */
|
|
842
|
+
}
|
|
843
|
+
const how = p.how === "replied" || p.how === "fixed" ? p.how : "ignored";
|
|
844
|
+
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 ?? "") });
|
|
845
|
+
}
|
|
846
|
+
else if (e.type === "contract.amended") {
|
|
847
|
+
let p = {};
|
|
848
|
+
try {
|
|
849
|
+
p = JSON.parse(e.payload || "{}");
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
/* keep {} */
|
|
853
|
+
}
|
|
854
|
+
onEvent({ type: "contract", agent: String(p.by ?? e.agent_id ?? ""), version: Number(p.version) || 0, section: String(p.section ?? ""), change: String(p.change ?? "") });
|
|
855
|
+
}
|
|
433
856
|
});
|
|
434
857
|
const tick = setInterval(() => {
|
|
858
|
+
opts.budget?.check();
|
|
435
859
|
watchSilence();
|
|
436
860
|
offerHelp();
|
|
861
|
+
buddyBySilence();
|
|
437
862
|
}, Math.max(50, Math.min(10_000, Math.floor(silenceMs / 4))));
|
|
438
863
|
tick.unref();
|
|
439
864
|
stopWatch = () => {
|
|
440
865
|
clearInterval(tick);
|
|
441
866
|
unwatch();
|
|
442
867
|
};
|
|
868
|
+
// Integration radar: compare in-flight agents' uncommitted work for
|
|
869
|
+
// conflicts and name mismatches while they can still fix them (radar.ts).
|
|
870
|
+
// Local agents only — a teammate's work lives on their machine.
|
|
871
|
+
if (opts.radarMs !== null)
|
|
872
|
+
stopRadar = startRadar({
|
|
873
|
+
db,
|
|
874
|
+
repoRoot,
|
|
875
|
+
intervalMs: opts.radarMs,
|
|
876
|
+
inFlight: () => [...running.values()]
|
|
877
|
+
// Still working: an exited agent's worktree is being committed and rebased by finish().
|
|
878
|
+
.filter((h) => h.child && h.child.exitCode === null && h.child.signalCode === null && !resolving.has(h.taskId))
|
|
879
|
+
.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 })),
|
|
880
|
+
// A finding is a note its reader must answer (fix it, or say why not) before huddle_done.
|
|
881
|
+
notify: (to, message, key) => {
|
|
882
|
+
const h = running.get(to);
|
|
883
|
+
const m = notifyAgent(db, to, message, "huddle", h?.taskId ?? null);
|
|
884
|
+
if (h)
|
|
885
|
+
trackNote(db, m, { by: "huddle", kind: "radar", worktree: h.worktree, key });
|
|
886
|
+
},
|
|
887
|
+
// The radar no longer sees it: whoever fixed it, every note about it is answered.
|
|
888
|
+
resolved: (key) => resolveNotes(db, key),
|
|
889
|
+
contractNames: () => contractNames(getContract(db)),
|
|
890
|
+
onEvent,
|
|
891
|
+
});
|
|
443
892
|
}
|
|
893
|
+
/** Notes that came back after the author exited: one more round to answer them, before anything merges. */
|
|
894
|
+
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. ` +
|
|
895
|
+
`Address each one: fix it, or reply huddle_send(to="<who>", reply_to="<id>", message="won't fix: <why>"). Then huddle_done.\n` +
|
|
896
|
+
notes.map((n) => `- [id ${n.id}, from ${n.kind === "radar" ? "huddle" : n.by}] ${cap(n.body, 800)}`).join("\n");
|
|
444
897
|
const finish = async (w, h, exitCode, why) => {
|
|
445
898
|
const workMs = Date.now() - h.startedAt; // agent time, not test/merge/approval time
|
|
446
899
|
running.delete(w.id);
|
|
@@ -449,14 +902,46 @@ export async function runBoard(opts) {
|
|
|
449
902
|
const task = db.getTask(h.taskId);
|
|
450
903
|
const quiet = silenced.get(task.id);
|
|
451
904
|
silenced.delete(task.id);
|
|
905
|
+
// A buddy check still being written is about THIS work. Found live (capstone
|
|
906
|
+
// round 4): 3 of 4 buddy notes came back after the author finished, and
|
|
907
|
+
// reached its next task instead. Hold the author (and its merge) for it, bounded.
|
|
908
|
+
const check = buddyRuns.get(task.id);
|
|
909
|
+
if (check && exitCode === 0 && !signal?.aborted) {
|
|
910
|
+
reserved.add(w.id);
|
|
911
|
+
pending++; // the run isn't over while this waits
|
|
912
|
+
let timer;
|
|
913
|
+
await Promise.race([check.landed, new Promise((r) => (timer = setTimeout(r, Math.max(0, check.at + buddyWaitMs - Date.now()))))]);
|
|
914
|
+
clearTimeout(timer);
|
|
915
|
+
pending--;
|
|
916
|
+
reserved.delete(w.id);
|
|
917
|
+
}
|
|
452
918
|
if (signal?.aborted) {
|
|
453
|
-
fail(w, h, task.title,
|
|
919
|
+
fail(w, h, task.title, stoppedWhy(), workMs);
|
|
454
920
|
}
|
|
455
921
|
else if (exitCode !== 0) {
|
|
456
922
|
failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
|
|
457
923
|
}
|
|
458
924
|
else {
|
|
925
|
+
// Notes it never saw (they came back after it exited). One it saw and didn't answer is just ignored.
|
|
926
|
+
const late = w.adapter ? openNotes(db, task.id).filter((n) => n.to === w.id && !db.getMessage(n.id)?.read_at) : [];
|
|
927
|
+
if (late.length && !followedUp.has(task.id)) {
|
|
928
|
+
followedUp.add(task.id);
|
|
929
|
+
for (const n of late)
|
|
930
|
+
db.markRead(n.id, "prompt"); // delivered in the brief, not the inbox
|
|
931
|
+
carry.set(task.id, totals(task.id, h, workMs));
|
|
932
|
+
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" });
|
|
933
|
+
start(w, task, followUpBrief(late));
|
|
934
|
+
return fill();
|
|
935
|
+
}
|
|
936
|
+
closeNotes(db, task.id); // each note: addressed or ignored (the note.closed watcher reports it)
|
|
459
937
|
db.setTaskStatus(task.id, "review");
|
|
938
|
+
// A paired task lands in a safe order (see pairUp): wait for its partner first.
|
|
939
|
+
const first = holdFor.get(task.id);
|
|
940
|
+
if (first && !settled(first)) {
|
|
941
|
+
held.push({ w, h, task, workMs, after: first });
|
|
942
|
+
pending++;
|
|
943
|
+
return fill();
|
|
944
|
+
}
|
|
460
945
|
return mergeTask(w, h, task, workMs);
|
|
461
946
|
}
|
|
462
947
|
fill();
|
|
@@ -489,6 +974,7 @@ export async function runBoard(opts) {
|
|
|
489
974
|
// Cross-review by an otherwise-idle agent: one round, never blocks on its own.
|
|
490
975
|
const reviewer = reviewed.has(task.id) ? undefined : idleReviewer(w.id);
|
|
491
976
|
let skippedReview = false;
|
|
977
|
+
let shallowReview = false;
|
|
492
978
|
if (reviewer) {
|
|
493
979
|
reviewed.add(task.id);
|
|
494
980
|
reviewsBy.set(reviewer.id, (reviewsBy.get(reviewer.id) ?? 0) + 1);
|
|
@@ -499,7 +985,7 @@ export async function runBoard(opts) {
|
|
|
499
985
|
onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
|
|
500
986
|
fill(); // everyone else keeps going
|
|
501
987
|
const t0 = Date.now();
|
|
502
|
-
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt)), wt.path, {
|
|
988
|
+
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
|
|
503
989
|
signal,
|
|
504
990
|
timeoutMs: 90_000,
|
|
505
991
|
noTools: true, // the diff is in the prompt — no need to explore
|
|
@@ -510,6 +996,7 @@ export async function runBoard(opts) {
|
|
|
510
996
|
db.setAgentStatus(reviewer.id, "idle");
|
|
511
997
|
pending--;
|
|
512
998
|
skippedReview = !!verdict.skipped;
|
|
999
|
+
shallowReview = !!verdict.shallow;
|
|
513
1000
|
onEvent({ type: "reviewed", agent: reviewer.id, author: w.id, task: task.id, title: task.title, ...verdict, ms: Date.now() - t0 });
|
|
514
1001
|
if (!verdict.approve && !signal?.aborted) {
|
|
515
1002
|
db.remember({ kind: "gotcha", body: `Review of "${task.title}" by ${reviewer.id}: ${verdict.notes}`, author: reviewer.id });
|
|
@@ -553,7 +1040,7 @@ export async function runBoard(opts) {
|
|
|
553
1040
|
recordHandoff(db, repoRoot, { taskId: task.id, agentId: w.id, title: task.title, base, since: firstStart.get(task.id) ?? h.startedAt });
|
|
554
1041
|
const resolvedBy = resolving.get(task.id)?.by.at(-1);
|
|
555
1042
|
const reason = prep.reason +
|
|
556
|
-
(reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}` : "") +
|
|
1043
|
+
(reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}${shallowReview ? " (shallow approve)" : ""}` : "") +
|
|
557
1044
|
(resolvedBy ? ` · conflict resolved by ${resolvedBy}` : "");
|
|
558
1045
|
onEvent({ type: "merged", agent: w.id, task: task.id, title: task.title, reason, ...totals(task.id, h, workMs) });
|
|
559
1046
|
}
|
|
@@ -576,6 +1063,7 @@ export async function runBoard(opts) {
|
|
|
576
1063
|
startedAt: Date.now(),
|
|
577
1064
|
meter: new UsageMeter(),
|
|
578
1065
|
};
|
|
1066
|
+
opts.budget?.track(h.meter, h.agentId);
|
|
579
1067
|
db.setTaskStatus(task.id, "in_progress");
|
|
580
1068
|
db.setAgentStatus(w.id, "working");
|
|
581
1069
|
running.set(w.id, h);
|
|
@@ -622,8 +1110,13 @@ export async function runBoard(opts) {
|
|
|
622
1110
|
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
1111
|
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
1112
|
}
|
|
1113
|
+
// The contract as it stands now (amendments included): each launch is a fresh process.
|
|
1114
|
+
const cb = contractBrief(db);
|
|
1115
|
+
// What the user said mid-run (room/user.ts) outranks the plan and the contract.
|
|
1116
|
+
const ub = userBrief(db);
|
|
1117
|
+
const contract = (cb ? `\n\n${cb}` : "") + (ub ? `\n\n${ub}` : "");
|
|
625
1118
|
if (w.remote)
|
|
626
|
-
return void startRemote(w, { ...task, spec: task.spec + ho.text }, extra);
|
|
1119
|
+
return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
|
|
627
1120
|
// Who else is mid-task right now, so this agent knows whom to tell (first round only;
|
|
628
1121
|
// fill() claims a whole wave before launching any of it, so siblings see each other).
|
|
629
1122
|
const along = first && !takeoverFrom ? alongsideBrief(db, task.id, w.id) : "";
|
|
@@ -635,7 +1128,7 @@ export async function runBoard(opts) {
|
|
|
635
1128
|
agentId: w.id,
|
|
636
1129
|
role: "worker",
|
|
637
1130
|
taskId: task.id,
|
|
638
|
-
taskSpec: `${task.title}\n\n${task.spec}${ho.text}${along}${extra}`,
|
|
1131
|
+
taskSpec: `${task.title}\n\n${task.spec}${contract}${ho.text}${along}${extra}`,
|
|
639
1132
|
roomUrl,
|
|
640
1133
|
roomSecret: opts.roomSecret,
|
|
641
1134
|
approvals: opts.approvals,
|
|
@@ -644,27 +1137,44 @@ export async function runBoard(opts) {
|
|
|
644
1137
|
});
|
|
645
1138
|
// Every agent's Plug wiring that sat in this worktree stays out of the commit.
|
|
646
1139
|
const h = { ...launched, excludes: [...new Set([...(prior?.excludes ?? []), ...launched.excludes])] };
|
|
1140
|
+
opts.budget?.track(h.meter, h.agentId);
|
|
647
1141
|
taskWt.set(task.id, { branch: h.branch, path: h.worktree, excludes: h.excludes });
|
|
648
1142
|
db.setTaskStatus(task.id, "in_progress");
|
|
649
1143
|
db.setAgentStatus(w.id, "working", h.worktree);
|
|
650
1144
|
running.set(w.id, h);
|
|
651
1145
|
heard(w.id, h);
|
|
652
1146
|
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));
|
|
1147
|
+
// On 'close', capped at 2s after 'exit': found live, an agent's final stdout
|
|
1148
|
+
// lines (its token usage, the log's tail) were lost when finish() ran on 'exit'.
|
|
1149
|
+
onFinished(h.child, (code) => void finish(w, h, code));
|
|
662
1150
|
};
|
|
663
1151
|
const fill = () => {
|
|
1152
|
+
// Paired tasks whose partner has landed (or failed: then nothing to wait for) merge now.
|
|
1153
|
+
// Every ready one is taken out before any merge starts: a merge re-enters fill().
|
|
1154
|
+
const ready = held.filter((x) => signal?.aborted || settled(x.after));
|
|
1155
|
+
if (ready.length) {
|
|
1156
|
+
held.splice(0, held.length, ...held.filter((x) => !ready.includes(x)));
|
|
1157
|
+
let merging = 0;
|
|
1158
|
+
for (const x of ready) {
|
|
1159
|
+
pending--;
|
|
1160
|
+
holdFor.delete(x.task.id);
|
|
1161
|
+
if (signal?.aborted)
|
|
1162
|
+
fail(x.w, x.h, x.task.title, stoppedWhy(), x.workMs);
|
|
1163
|
+
// Tests for code that never landed would only go red at the gate and burn a fix round.
|
|
1164
|
+
else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
|
|
1165
|
+
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);
|
|
1166
|
+
else {
|
|
1167
|
+
merging++;
|
|
1168
|
+
void mergeTask(x.w, x.h, x.task, x.workMs);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (merging)
|
|
1172
|
+
return; // each merge fills again when it's done
|
|
1173
|
+
}
|
|
664
1174
|
// Conflicts first: they hold finished work, and the author is usually free.
|
|
665
1175
|
for (const c of conflicts.splice(0)) {
|
|
666
1176
|
if (signal?.aborted) {
|
|
667
|
-
parkForYou(c,
|
|
1177
|
+
parkForYou(c, stoppedWhy());
|
|
668
1178
|
continue;
|
|
669
1179
|
}
|
|
670
1180
|
const r = pickResolver(c.author, resolving.get(c.taskId)?.by ?? []);
|
|
@@ -680,13 +1190,17 @@ export async function runBoard(opts) {
|
|
|
680
1190
|
fail(rc.from, rc.h, title, `${rc.reason} (stopped before another agent could take it over)`, 0);
|
|
681
1191
|
continue;
|
|
682
1192
|
}
|
|
1193
|
+
if (overBudget()) {
|
|
1194
|
+
fail(rc.from, rc.h, title, `${rc.reason} (not taken over: the token budget is nearly spent)`, 0);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
683
1197
|
const r = pickRescuer(rc);
|
|
684
1198
|
if (r)
|
|
685
1199
|
startRescue(r, rc);
|
|
686
1200
|
else
|
|
687
1201
|
rescues.push(rc); // the next other agent to finish takes it
|
|
688
1202
|
}
|
|
689
|
-
if (!signal?.aborted) {
|
|
1203
|
+
if (!signal?.aborted && !overBudget()) {
|
|
690
1204
|
// Fairness: the free agent that has taken the fewest tasks goes first
|
|
691
1205
|
// (roster order breaks ties), so one agent doesn't take every task that
|
|
692
1206
|
// unblocks just because it's listed first.
|
|
@@ -730,14 +1244,25 @@ export async function runBoard(opts) {
|
|
|
730
1244
|
start(w, task);
|
|
731
1245
|
}
|
|
732
1246
|
offerHelp();
|
|
733
|
-
|
|
1247
|
+
// Nobody is working and only paired merges wait: their partner can't land now, so don't wait for it.
|
|
1248
|
+
if (running.size === 0 && held.length && pending === held.length && !pairing.size) {
|
|
1249
|
+
const x = held.shift();
|
|
1250
|
+
pending--;
|
|
1251
|
+
holdFor.delete(x.task.id);
|
|
1252
|
+
void mergeTask(x.w, x.h, x.task, x.workMs);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (running.size === 0 && pending === 0 && !pairing.size) {
|
|
734
1256
|
// Nobody is running yet a conflict still waits: no agent on this machine can resolve it.
|
|
735
1257
|
for (const c of conflicts.splice(0))
|
|
736
1258
|
parkForYou(c, "no agent on this machine could take the resolution");
|
|
737
1259
|
for (const rc of rescues.splice(0))
|
|
738
1260
|
fail(rc.from, rc.h, db.getTask(rc.taskId).title, `${rc.reason} (no other agent could take it over)`, 0);
|
|
739
1261
|
const blocked = db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
|
|
740
|
-
|
|
1262
|
+
const b = opts.budget;
|
|
1263
|
+
if (b?.limit && b.level !== "ok")
|
|
1264
|
+
onEvent({ type: "budget", level: b.level === "hard" ? "hard" : "soft", tokens: b.tokens, limit: b.limit, left: blocked });
|
|
1265
|
+
else if (blocked.length && !signal?.aborted)
|
|
741
1266
|
onEvent({ type: "stuck", blocked });
|
|
742
1267
|
done();
|
|
743
1268
|
}
|