huddle-cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
// The scheduler: keeps every ready agent busy until the board is empty.
|
|
2
|
+
//
|
|
3
|
+
// Loop: any idle agent takes the best unblocked backlog task for its strengths
|
|
4
|
+
// (dependencies must be merged first). When its process exits, the merge engine
|
|
5
|
+
// runs the test gate; in manual mode you approve the merge; landing it can
|
|
6
|
+
// unblock more tasks, so we fill again. Nobody idles while there's work.
|
|
7
|
+
import { launchAgent, stopChild } from "./launcher.js";
|
|
8
|
+
import { prepareMerge, landMerge, removeWorktree, branchDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
|
|
9
|
+
import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
|
|
10
|
+
import { UsageMeter } from "../adapters/usage.js";
|
|
11
|
+
import { askAgent } from "./ask.js";
|
|
12
|
+
import { handoffsFor, headSha, recordHandoff } from "./handoff.js";
|
|
13
|
+
import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv } from "./takeover.js";
|
|
14
|
+
import { helpOffer, alongsideBrief } from "../room/progress.js";
|
|
15
|
+
const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
|
|
16
|
+
/** No help offers on a task whose reported progress is this far along. */
|
|
17
|
+
export const OFFER_MAX_PCT = 60;
|
|
18
|
+
/** Resolver runs per conflicted task before it's parked for you. */
|
|
19
|
+
export const MAX_RESOLVE_ATTEMPTS = 2;
|
|
20
|
+
const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
|
|
21
|
+
function resolvePrompt(o) {
|
|
22
|
+
return `Your branch for this task is finished, but another agent's change landed on main touching the same lines.
|
|
23
|
+
Huddle is merging main INTO this task's branch in your current directory, and git stopped on conflicts.
|
|
24
|
+
Your job is ONLY to resolve them so that BOTH changes survive and work together.
|
|
25
|
+
|
|
26
|
+
THIS TASK (its change is "this task" below): ${o.title}
|
|
27
|
+
${cap(o.spec, 1500)}
|
|
28
|
+
|
|
29
|
+
CONFLICTED FILES, with git's markers (<<<<<<< this task · ||||||| common base · ======= · >>>>>>> main):
|
|
30
|
+
${o.brief.marked}
|
|
31
|
+
|
|
32
|
+
WHAT THIS TASK CHANGED (base → this task):
|
|
33
|
+
${o.brief.oursDiff || "(no diff)"}
|
|
34
|
+
|
|
35
|
+
WHAT LANDED ON MAIN MEANWHILE (base → main):
|
|
36
|
+
${o.brief.theirsDiff || "(no diff)"}
|
|
37
|
+
|
|
38
|
+
THE OTHER WORK ON MAIN, and what its agents noted:
|
|
39
|
+
${o.others || "(no notes)"}
|
|
40
|
+
${o.retry ? `\nA previous attempt was rejected: ${o.retry}\n` : ""}
|
|
41
|
+
HOW:
|
|
42
|
+
1. Edit each conflicted file: remove every marker line and write ONE version in which both changes work together.
|
|
43
|
+
2. Never take one side wholesale (no "git checkout --ours/--theirs", no copying one version over the other):
|
|
44
|
+
Huddle rejects a file identical to either side, because that silently drops a teammate's work.
|
|
45
|
+
3. Change only what the combination needs. Don't commit, merge, rebase or reset — Huddle commits the
|
|
46
|
+
resolution and re-runs the tests before anything lands.
|
|
47
|
+
4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
|
|
48
|
+
}
|
|
49
|
+
function reviewPrompt(title, spec, diff) {
|
|
50
|
+
return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
|
|
51
|
+
|
|
52
|
+
TASK: ${title}
|
|
53
|
+
${spec}
|
|
54
|
+
|
|
55
|
+
DIFF (against main):
|
|
56
|
+
${diff || "(empty diff)"}
|
|
57
|
+
|
|
58
|
+
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 diff against the task: bugs, broken contracts, missing requirements, behavior that would fail.
|
|
60
|
+
Reply with the first line exactly APPROVE or CHANGES.
|
|
61
|
+
If CHANGES, add up to 6 short lines, each one concrete blocking problem and where it is.
|
|
62
|
+
Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you have. Do not edit anything.`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The first APPROVE/CHANGES line decides. Anything else is "skipped" — shown
|
|
66
|
+
* honestly, and it never blocks a merge (a reviewer can't block by accident).
|
|
67
|
+
*/
|
|
68
|
+
function parseVerdict(text) {
|
|
69
|
+
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
70
|
+
const i = lines.findIndex((l) => /^(APPROVE|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
|
|
71
|
+
if (i < 0)
|
|
72
|
+
return { approve: true, skipped: true, notes: "no verdict" };
|
|
73
|
+
if (lines[i].replace(/[*`#_]/g, "").toUpperCase().startsWith("CHANGES"))
|
|
74
|
+
return { approve: false, notes: lines.slice(i + 1, i + 7).join("\n") || "(no details given)" };
|
|
75
|
+
return { approve: true, notes: lines.slice(i + 1, i + 3).join(" ") };
|
|
76
|
+
}
|
|
77
|
+
export async function runBoard(opts) {
|
|
78
|
+
const { db, repoRoot, roomUrl, workers, testCmd, signal } = opts;
|
|
79
|
+
// Every scheduler event (but the per-tick token meter) is also kept in the
|
|
80
|
+
// Room db as "run.<type>", so `huddle story` can replay a run afterwards.
|
|
81
|
+
const onEvent = (e) => {
|
|
82
|
+
if (e.type !== "tokens") {
|
|
83
|
+
try {
|
|
84
|
+
const ev = e;
|
|
85
|
+
db.publish(`run.${e.type}`, { agent_id: ev.agent ?? null, task_id: ev.task ?? null, payload: e });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* a closed db never breaks a run */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
opts.onEvent(e);
|
|
92
|
+
};
|
|
93
|
+
const running = new Map(); // agent id -> handle
|
|
94
|
+
let merged = 0;
|
|
95
|
+
let failed = 0;
|
|
96
|
+
let pending = 0; // merges awaiting approval
|
|
97
|
+
const all = () => [...workers, ...(opts.remoteWorkers?.() ?? [])];
|
|
98
|
+
const known = new Set();
|
|
99
|
+
const register = (w) => {
|
|
100
|
+
if (known.has(w.id))
|
|
101
|
+
return;
|
|
102
|
+
known.add(w.id);
|
|
103
|
+
db.upsertAgent({
|
|
104
|
+
id: w.id,
|
|
105
|
+
vendor: w.adapter?.vendor ?? `${w.remote?.member}'s`,
|
|
106
|
+
model: w.adapter?.models.worker || "default",
|
|
107
|
+
role: "worker",
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
workers.forEach(register);
|
|
111
|
+
const unavailable = new Set(); // teammates' agents that declined this run
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
let resolved = false;
|
|
114
|
+
let stopWatch = () => { }; // the silence watchdog + help offers (set up below)
|
|
115
|
+
const done = () => {
|
|
116
|
+
if (resolved)
|
|
117
|
+
return;
|
|
118
|
+
resolved = true;
|
|
119
|
+
stopWatch();
|
|
120
|
+
process.off("exit", killAll);
|
|
121
|
+
resolve({ merged, failed, stopped: !!signal?.aborted });
|
|
122
|
+
};
|
|
123
|
+
const killAll = () => {
|
|
124
|
+
for (const h of running.values())
|
|
125
|
+
stopChild(h.child);
|
|
126
|
+
};
|
|
127
|
+
signal?.addEventListener("abort", killAll);
|
|
128
|
+
// If Huddle itself exits, never leave orphaned agents working for nobody.
|
|
129
|
+
process.once("exit", killAll);
|
|
130
|
+
// Agents held out of fill(): an author waiting on review, a reviewer reviewing.
|
|
131
|
+
const reserved = new Set();
|
|
132
|
+
const reviewed = new Set(); // task ids that already had their one review round
|
|
133
|
+
const testFixed = new Set(); // task ids that already had their one red-tests fix round
|
|
134
|
+
const carry = new Map(); // earlier rounds
|
|
135
|
+
const taken = new Map(); // agent id -> backlog tasks it has claimed this run (fairness in fill)
|
|
136
|
+
const firstStart = new Map(); // task id -> when its first round began (handoff notes window)
|
|
137
|
+
const totals = (taskId, h, ms) => {
|
|
138
|
+
const c = carry.get(taskId) ?? { ms: 0, tokens: 0, cached: 0 };
|
|
139
|
+
return { ms: c.ms + ms, tokens: c.tokens + h.meter.tokens, cached: c.cached + h.meter.cached };
|
|
140
|
+
};
|
|
141
|
+
const fail = (w, h, title, reason, ms) => {
|
|
142
|
+
db.setTaskStatus(h.taskId, "failed");
|
|
143
|
+
db.remember({ kind: "gotcha", body: `Task "${title}" didn't merge: ${reason}`, author: "huddle" });
|
|
144
|
+
failed++;
|
|
145
|
+
onEvent({ type: "failed", agent: w.id, task: h.taskId, title, reason, log: h.logPath, ...totals(h.taskId, h, ms) });
|
|
146
|
+
};
|
|
147
|
+
const reviewsBy = new Map(); // agent id -> reviews it gave this run
|
|
148
|
+
/**
|
|
149
|
+
* An agent with nothing else it could do right now — the no-idle rule's
|
|
150
|
+
* reviewer (local only). Fewest reviews given so far goes first, roster
|
|
151
|
+
* order breaks ties. Found live (capstone round 2): roster-first claude was
|
|
152
|
+
* the idle one at almost every merge and did 4 of the 6 reviews.
|
|
153
|
+
*/
|
|
154
|
+
const idleReviewer = (authorId) => workers
|
|
155
|
+
.filter((w) => w.id !== authorId &&
|
|
156
|
+
!running.has(w.id) &&
|
|
157
|
+
!reserved.has(w.id) &&
|
|
158
|
+
w.adapter?.ask &&
|
|
159
|
+
!db.nextBacklogTask(prefersOf(w)))
|
|
160
|
+
.map((w, i) => ({ w, i }))
|
|
161
|
+
.sort((a, b) => (reviewsBy.get(a.w.id) ?? 0) - (reviewsBy.get(b.w.id) ?? 0) || a.i - b.i)[0]?.w;
|
|
162
|
+
// --- merge conflicts -----------------------------------------------------
|
|
163
|
+
// A branch that can't rebase onto main goes to a resolver: the author if
|
|
164
|
+
// it's free (it knows the change), else any free agent, fresh eyes on a
|
|
165
|
+
// retry. Its work is checked (no markers, neither side dropped), the tests
|
|
166
|
+
// re-run, and it lands. After MAX_RESOLVE_ATTEMPTS it's parked for you —
|
|
167
|
+
// main untouched, the branch exactly as the author left it.
|
|
168
|
+
const conflicts = []; // waiting for a free agent
|
|
169
|
+
const resolving = new Map(); // task id -> attempts
|
|
170
|
+
const resolveConflict = (w, h, taskId, ms) => {
|
|
171
|
+
carry.set(taskId, totals(taskId, h, ms));
|
|
172
|
+
db.setTaskStatus(taskId, "review");
|
|
173
|
+
conflicts.push({ author: w, h: { ...h, meter: new UsageMeter() }, taskId }); // its tokens are in carry now
|
|
174
|
+
};
|
|
175
|
+
const parkForYou = (c, reason) => {
|
|
176
|
+
const task = db.getTask(c.taskId);
|
|
177
|
+
db.setTaskStatus(task.id, "needs_you");
|
|
178
|
+
db.remember({
|
|
179
|
+
kind: "gotcha",
|
|
180
|
+
body: `Task "${task.title}" conflicts with main and needs you: ${reason}. Nothing landed; the work is on branch ${c.h.branch}.`,
|
|
181
|
+
author: "huddle",
|
|
182
|
+
});
|
|
183
|
+
failed++;
|
|
184
|
+
const t = carry.get(task.id) ?? { ms: 0, tokens: 0, cached: 0 };
|
|
185
|
+
onEvent({
|
|
186
|
+
type: "failed",
|
|
187
|
+
needsYou: true,
|
|
188
|
+
agent: c.author.id,
|
|
189
|
+
task: task.id,
|
|
190
|
+
title: task.title,
|
|
191
|
+
reason: `merge conflict needs you — ${reason}; main untouched, work kept on branch ${c.h.branch} (${c.h.worktree})`,
|
|
192
|
+
log: c.h.logPath,
|
|
193
|
+
...t,
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
const pickResolver = (author, tried) => {
|
|
197
|
+
const free = workers.filter((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id));
|
|
198
|
+
return (free.find((x) => x.id === author.id && !tried.includes(x.id)) ??
|
|
199
|
+
free.find((x) => !tried.includes(x.id)) ??
|
|
200
|
+
free.find((x) => x.id === author.id) ??
|
|
201
|
+
free[0]);
|
|
202
|
+
};
|
|
203
|
+
const startResolve = (r, c) => {
|
|
204
|
+
const task = db.getTask(c.taskId);
|
|
205
|
+
const wt = { branch: c.h.branch, path: c.h.worktree };
|
|
206
|
+
const st = resolving.get(task.id) ?? { n: 0, by: [] };
|
|
207
|
+
if (st.n >= MAX_RESOLVE_ATTEMPTS)
|
|
208
|
+
return parkForYou(c, `${st.n} resolver attempts didn't hold (last: ${st.last})`);
|
|
209
|
+
const cs = beginConflictMerge(repoRoot, wt, `huddle: merge main into "${task.title}"`);
|
|
210
|
+
if ("error" in cs)
|
|
211
|
+
return parkForYou(c, cs.error);
|
|
212
|
+
if (!cs.files.length)
|
|
213
|
+
return void mergeTask(c.author, c.h, task, 0); // main moved on and it now merges cleanly
|
|
214
|
+
st.n++;
|
|
215
|
+
st.by.push(r.id);
|
|
216
|
+
resolving.set(task.id, st);
|
|
217
|
+
const brief = conflictBrief(wt, cs);
|
|
218
|
+
const others = brief.theirCommits
|
|
219
|
+
.map((subject) => {
|
|
220
|
+
const title = /^huddle\(([^)]*)\): (.*)$/.exec(subject)?.[2];
|
|
221
|
+
const t = title ? db.listTasks().find((x) => x.title === title) : undefined;
|
|
222
|
+
if (!t)
|
|
223
|
+
return `- ${subject}`;
|
|
224
|
+
// Its done-summary, then what its agent wrote down (decisions say WHY the lines changed).
|
|
225
|
+
const done = db.recall(`Task ${t.id} done:`);
|
|
226
|
+
const notes = [...done, ...db.recall().filter((m) => m.author === t.claimed_by && !done.some((d) => d.id === m.id))];
|
|
227
|
+
return [`- "${t.title}" (${t.claimed_by ?? "?"}): ${cap(t.spec, 600)}`, ...notes.slice(0, 4).map((m) => ` note (${m.author}): ${cap(m.body, 300)}`)].join("\n");
|
|
228
|
+
})
|
|
229
|
+
.join("\n");
|
|
230
|
+
const h2 = launchAgent({
|
|
231
|
+
repoRoot,
|
|
232
|
+
adapter: r.adapter,
|
|
233
|
+
agentId: r.id,
|
|
234
|
+
role: "worker",
|
|
235
|
+
taskId: task.id,
|
|
236
|
+
taskSpec: `Resolve merge conflict: ${task.title}\n\n${resolvePrompt({ title: task.title, spec: task.spec, brief, others, retry: st.last })}`,
|
|
237
|
+
roomUrl,
|
|
238
|
+
roomSecret: opts.roomSecret,
|
|
239
|
+
approvals: opts.approvals,
|
|
240
|
+
worktree: wt,
|
|
241
|
+
onTokens: (tokens) => onEvent({ type: "tokens", agent: r.id, task: task.id, tokens }),
|
|
242
|
+
});
|
|
243
|
+
db.setTaskStatus(task.id, "in_progress");
|
|
244
|
+
db.setAgentStatus(r.id, "working", wt.path);
|
|
245
|
+
running.set(r.id, h2);
|
|
246
|
+
heard(r.id, h2);
|
|
247
|
+
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
|
+
let exited = false;
|
|
249
|
+
const once = (code) => {
|
|
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));
|
|
257
|
+
};
|
|
258
|
+
const endResolve = async (r, c, h2, cs, code) => {
|
|
259
|
+
const ms = Date.now() - h2.startedAt;
|
|
260
|
+
running.delete(r.id);
|
|
261
|
+
db.releaseClaims(r.id);
|
|
262
|
+
db.setAgentStatus(r.id, "idle");
|
|
263
|
+
const task = db.getTask(c.taskId);
|
|
264
|
+
silenced.delete(task.id); // a silent resolver is just a failed attempt
|
|
265
|
+
const wt = { branch: c.h.branch, path: c.h.worktree };
|
|
266
|
+
// Both agents' Plug wiring may sit in this worktree; neither may be committed.
|
|
267
|
+
const h = { ...h2, excludes: [...new Set([...c.h.excludes, ...h2.excludes])] };
|
|
268
|
+
const res = signal?.aborted
|
|
269
|
+
? { ok: false, reason: "stopped by you" }
|
|
270
|
+
: code === 0
|
|
271
|
+
? finishConflictMerge(wt, cs, h.excludes)
|
|
272
|
+
: { ok: false, reason: `resolver exited with code ${code}` };
|
|
273
|
+
onEvent({ type: "resolved", agent: r.id, author: c.author.id, task: task.id, title: task.title, ok: res.ok, reason: res.reason, ms });
|
|
274
|
+
if (res.ok) {
|
|
275
|
+
db.remember({ kind: "decision", body: `Merge conflict in "${task.title}" (${cs.files.join(", ")}) resolved by ${r.id}; tests re-run before it lands`, author: "huddle" });
|
|
276
|
+
db.setTaskStatus(task.id, "review");
|
|
277
|
+
return mergeTask(c.author, h, task, ms); // tests again → review → land
|
|
278
|
+
}
|
|
279
|
+
abortConflictMerge(wt, cs); // the branch is the author's own commits again
|
|
280
|
+
resolving.get(task.id).last = res.reason;
|
|
281
|
+
db.remember({ kind: "gotcha", body: `Resolving the conflict in "${task.title}" (${r.id}) didn't hold: ${res.reason}`, author: "huddle" });
|
|
282
|
+
resolveConflict(c.author, h, task.id, ms);
|
|
283
|
+
fill();
|
|
284
|
+
};
|
|
285
|
+
// --- takeovers ------------------------------------------------------------
|
|
286
|
+
// Rescue: a task whose agent crashed, stayed red after its fix round, or
|
|
287
|
+
// went silent goes to a DIFFERENT free agent, which continues in the same
|
|
288
|
+
// worktree with a brief (takeover.ts). One takeover per task; a rescuer
|
|
289
|
+
// that can't land it either parks it for you, branch kept. Split: a busy
|
|
290
|
+
// agent's huddle_split puts a part on the board and a free agent takes it.
|
|
291
|
+
const silenceMs = opts.silenceMs ?? silenceMsFromEnv();
|
|
292
|
+
const helpAfterMs = opts.helpAfterMs !== undefined ? opts.helpAfterMs : helpAfterMsFromEnv();
|
|
293
|
+
const taskWt = new Map(); // task id -> its worktree, whoever works it
|
|
294
|
+
const lastSeen = new Map(); // agent id -> its last output or huddle_* call
|
|
295
|
+
const silenced = new Map(); // task id -> why we stopped its agent
|
|
296
|
+
/** Start listening for signs of life from a freshly launched agent. */
|
|
297
|
+
const heard = (id, h) => {
|
|
298
|
+
const bump = () => lastSeen.set(id, Date.now());
|
|
299
|
+
bump();
|
|
300
|
+
h.child?.stdout?.on("data", bump);
|
|
301
|
+
h.child?.stderr?.on("data", bump);
|
|
302
|
+
};
|
|
303
|
+
const rescues = []; // waiting for a free agent other than the author
|
|
304
|
+
const takenOver = new Map(); // task id -> its one takeover
|
|
305
|
+
const offered = new Set(); // task ids a free agent already offered to help with
|
|
306
|
+
const splitFrom = new Map(); // part task id -> who split it off what
|
|
307
|
+
/** Fail it — or, the first time, hand it to another agent to finish from the same branch. */
|
|
308
|
+
const failOrRescue = (w, h, task, reason, ms, testOutput) => {
|
|
309
|
+
const prior = takenOver.get(task.id);
|
|
310
|
+
if (prior)
|
|
311
|
+
return parkTakeover(w, h, task, reason, ms, prior.from);
|
|
312
|
+
const someoneElse = workers.some((x) => x.adapter && x.id !== w.id);
|
|
313
|
+
if (signal?.aborted || w.remote || !someoneElse)
|
|
314
|
+
return fail(w, h, task.title, reason, ms);
|
|
315
|
+
carry.set(task.id, totals(task.id, h, ms));
|
|
316
|
+
db.setTaskStatus(task.id, "claimed"); // held (files stay busy) until a rescuer starts
|
|
317
|
+
db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) stopped: ${reason} — another agent takes it over from its branch`, author: "huddle" });
|
|
318
|
+
rescues.push({ from: w, h: { ...h, meter: new UsageMeter() }, taskId: task.id, reason, testOutput }); // its tokens are in carry now
|
|
319
|
+
};
|
|
320
|
+
const parkTakeover = (w, h, task, reason, ms, from) => {
|
|
321
|
+
db.setTaskStatus(task.id, "needs_you");
|
|
322
|
+
db.remember({
|
|
323
|
+
kind: "gotcha",
|
|
324
|
+
body: `Task "${task.title}" needs you: ${from} and then ${w.id} couldn't land it (last: ${reason}). Nothing landed; the work is on branch ${h.branch}.`,
|
|
325
|
+
author: "huddle",
|
|
326
|
+
});
|
|
327
|
+
failed++;
|
|
328
|
+
onEvent({
|
|
329
|
+
type: "failed",
|
|
330
|
+
needsYou: true,
|
|
331
|
+
agent: w.id,
|
|
332
|
+
task: task.id,
|
|
333
|
+
title: task.title,
|
|
334
|
+
reason: `needs you — taken over from ${from}, still not landing: ${reason}; main untouched, work kept on branch ${h.branch} (${h.worktree})`,
|
|
335
|
+
log: h.logPath,
|
|
336
|
+
...totals(task.id, h, ms),
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
/** A different free local agent: another vendor first (fresh eyes), then one with nothing else to do. */
|
|
340
|
+
const pickRescuer = (rc) => {
|
|
341
|
+
const vendor = rc.from.adapter?.vendor;
|
|
342
|
+
const score = (x) => (x.adapter.vendor === vendor ? 2 : 0) + (db.nextBacklogTask(prefersOf(x)) ? 1 : 0) + (taken.get(x.id) ?? 0) / 100;
|
|
343
|
+
return workers
|
|
344
|
+
.filter((x) => x.adapter && x.id !== rc.from.id && !running.has(x.id) && !reserved.has(x.id))
|
|
345
|
+
.sort((a, b) => score(a) - score(b))[0];
|
|
346
|
+
};
|
|
347
|
+
const startRescue = (r, rc) => {
|
|
348
|
+
const task = db.getTask(rc.taskId);
|
|
349
|
+
takenOver.set(task.id, { from: rc.from.id, by: r.id });
|
|
350
|
+
db.reassignTask(task.id, r.id);
|
|
351
|
+
// The author's rounds are reported here: no merged/failed event will carry them.
|
|
352
|
+
const t = carry.get(task.id) ?? { ms: 0, tokens: 0, cached: 0 };
|
|
353
|
+
carry.delete(task.id);
|
|
354
|
+
db.publish("task.takeover", {
|
|
355
|
+
task_id: task.id,
|
|
356
|
+
agent_id: r.id,
|
|
357
|
+
payload: { kind: "rescue", from: rc.from.id, to: r.id, title: task.title, reason: rc.reason, branch: rc.h.branch },
|
|
358
|
+
});
|
|
359
|
+
db.remember({ kind: "decision", body: `${r.id} took over ${rc.from.id}'s "${task.title}" (${rc.reason}) and continues on its branch`, author: "huddle" });
|
|
360
|
+
onEvent({ type: "takeover", kind: "rescue", agent: r.id, from: rc.from.id, task: task.id, title: task.title, reason: rc.reason, ...t });
|
|
361
|
+
taken.set(r.id, (taken.get(r.id) ?? 0) + 1);
|
|
362
|
+
const brief = takeoverBrief(db, {
|
|
363
|
+
taskId: task.id,
|
|
364
|
+
from: rc.from.id,
|
|
365
|
+
reason: rc.reason,
|
|
366
|
+
branch: rc.h.branch,
|
|
367
|
+
since: firstStart.get(task.id) ?? rc.h.startedAt,
|
|
368
|
+
testOutput: rc.testOutput,
|
|
369
|
+
logPath: rc.h.logPath,
|
|
370
|
+
});
|
|
371
|
+
start(r, task, brief, rc.from.id);
|
|
372
|
+
};
|
|
373
|
+
/** Stop agents that have gone quiet; they're taken over in finish(). */
|
|
374
|
+
const watchSilence = () => {
|
|
375
|
+
for (const [id, h] of running) {
|
|
376
|
+
if (!h.child || silenced.has(h.taskId) || h.child.exitCode !== null || h.child.signalCode !== null)
|
|
377
|
+
continue;
|
|
378
|
+
const quiet = Date.now() - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
|
|
379
|
+
if (quiet < silenceMs)
|
|
380
|
+
continue;
|
|
381
|
+
const span = quiet >= 60_000 ? `${Math.round(quiet / 60_000)} min` : `${Math.round(quiet / 1000)}s`;
|
|
382
|
+
silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span}`);
|
|
383
|
+
h.child.kill("SIGTERM");
|
|
384
|
+
const child = h.child;
|
|
385
|
+
setTimeout(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"), 5000).unref();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
/** A free agent with nothing to do offers to take part of each long-running task, once. */
|
|
389
|
+
const offerHelp = () => {
|
|
390
|
+
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
|
|
391
|
+
return;
|
|
392
|
+
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
393
|
+
if (!helper)
|
|
394
|
+
return;
|
|
395
|
+
for (const [id, h] of running) {
|
|
396
|
+
if (id === helper.id || offered.has(h.taskId) || resolving.has(h.taskId) || Date.now() - h.startedAt < helpAfterMs)
|
|
397
|
+
continue;
|
|
398
|
+
const t = db.getTask(h.taskId);
|
|
399
|
+
if (!t || t.claimed_by !== id)
|
|
400
|
+
continue;
|
|
401
|
+
// Nearly done (its own huddle_progress says so): nothing left worth
|
|
402
|
+
// splitting. Found live (capstone round 2): offers landed on tasks at
|
|
403
|
+
// 70-100%, every one was ignored, and they were most of the inbox traffic.
|
|
404
|
+
if ((t.progress_pct ?? 0) >= OFFER_MAX_PCT)
|
|
405
|
+
continue;
|
|
406
|
+
offered.add(t.id);
|
|
407
|
+
db.sendMessage({ from: helper.id, to: id, kind: "note", task_id: t.id, body: helpOffer(helper.id, t.id, t.title) });
|
|
408
|
+
onEvent({ type: "offer", agent: helper.id, to: id, task: t.id, title: t.title });
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
{
|
|
412
|
+
const unwatch = db.onEvent((e) => {
|
|
413
|
+
if (resolved)
|
|
414
|
+
return;
|
|
415
|
+
if (e.type === "plug.call" && e.agent_id && running.has(e.agent_id))
|
|
416
|
+
lastSeen.set(e.agent_id, Date.now());
|
|
417
|
+
else if (e.type === "task.progress" && e.task_id) {
|
|
418
|
+
const by = db.getTask(e.task_id)?.claimed_by;
|
|
419
|
+
if (by && running.has(by))
|
|
420
|
+
lastSeen.set(by, Date.now());
|
|
421
|
+
}
|
|
422
|
+
else if (e.type === "task.split" && e.task_id) {
|
|
423
|
+
let p = {};
|
|
424
|
+
try {
|
|
425
|
+
p = JSON.parse(e.payload || "{}");
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
/* keep {} */
|
|
429
|
+
}
|
|
430
|
+
splitFrom.set(e.task_id, { from: String(p.from ?? e.agent_id ?? ""), parentTitle: String(p.parent_title ?? "") });
|
|
431
|
+
setImmediate(() => !resolved && fill()); // a free agent may take it right away
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
const tick = setInterval(() => {
|
|
435
|
+
watchSilence();
|
|
436
|
+
offerHelp();
|
|
437
|
+
}, Math.max(50, Math.min(10_000, Math.floor(silenceMs / 4))));
|
|
438
|
+
tick.unref();
|
|
439
|
+
stopWatch = () => {
|
|
440
|
+
clearInterval(tick);
|
|
441
|
+
unwatch();
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const finish = async (w, h, exitCode, why) => {
|
|
445
|
+
const workMs = Date.now() - h.startedAt; // agent time, not test/merge/approval time
|
|
446
|
+
running.delete(w.id);
|
|
447
|
+
db.releaseClaims(w.id);
|
|
448
|
+
db.setAgentStatus(w.id, "idle");
|
|
449
|
+
const task = db.getTask(h.taskId);
|
|
450
|
+
const quiet = silenced.get(task.id);
|
|
451
|
+
silenced.delete(task.id);
|
|
452
|
+
if (signal?.aborted) {
|
|
453
|
+
fail(w, h, task.title, "stopped by you", workMs);
|
|
454
|
+
}
|
|
455
|
+
else if (exitCode !== 0) {
|
|
456
|
+
failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
db.setTaskStatus(task.id, "review");
|
|
460
|
+
return mergeTask(w, h, task, workMs);
|
|
461
|
+
}
|
|
462
|
+
fill();
|
|
463
|
+
};
|
|
464
|
+
/** Test gate → cross-review → your approval → land. Re-entered after a conflict is resolved. */
|
|
465
|
+
const mergeTask = async (w, h, task, workMs) => {
|
|
466
|
+
const wt = { branch: h.branch, path: h.worktree };
|
|
467
|
+
const prep = prepareMerge(repoRoot, wt, testCmd(wt.path), h.excludes, `huddle(${w.id}): ${task.title}`);
|
|
468
|
+
if (!prep.ok) {
|
|
469
|
+
// Another agent's change landed on the same lines: hand it to a resolver, never drop it.
|
|
470
|
+
if (prep.conflict)
|
|
471
|
+
resolveConflict(w, h, task.id, workMs);
|
|
472
|
+
else if (prep.testOutput !== undefined && !testFixed.has(task.id) && !w.remote && !resolving.has(task.id) && !signal?.aborted) {
|
|
473
|
+
// Red test gate: one fix round for the author, in the same worktree,
|
|
474
|
+
// with the failing output in hand (found live: an agent finished with
|
|
475
|
+
// a test it hadn't been told it could fix). A second red gate fails it.
|
|
476
|
+
testFixed.add(task.id);
|
|
477
|
+
carry.set(task.id, totals(task.id, h, workMs));
|
|
478
|
+
db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) hit a red test gate; one fix round`, author: "huddle" });
|
|
479
|
+
start(w, task, `\n\nHuddle's merge gate ran the full test suite (\`${testCmd(wt.path)}\`) on your work and it FAILED — nothing was merged. ` +
|
|
480
|
+
`Make the whole suite pass, including tests you didn't write; if that needs a file outside your planned list, ` +
|
|
481
|
+
`huddle_announce_plan it and edit it. Then huddle_done. Test output (tail):\n${prep.testOutput || "(no output)"}`);
|
|
482
|
+
}
|
|
483
|
+
else if (prep.testOutput !== undefined)
|
|
484
|
+
failOrRescue(w, h, task, prep.reason, workMs, prep.testOutput);
|
|
485
|
+
else
|
|
486
|
+
fail(w, h, task.title, prep.reason, workMs);
|
|
487
|
+
return fill();
|
|
488
|
+
}
|
|
489
|
+
// Cross-review by an otherwise-idle agent: one round, never blocks on its own.
|
|
490
|
+
const reviewer = reviewed.has(task.id) ? undefined : idleReviewer(w.id);
|
|
491
|
+
let skippedReview = false;
|
|
492
|
+
if (reviewer) {
|
|
493
|
+
reviewed.add(task.id);
|
|
494
|
+
reviewsBy.set(reviewer.id, (reviewsBy.get(reviewer.id) ?? 0) + 1);
|
|
495
|
+
reserved.add(w.id);
|
|
496
|
+
reserved.add(reviewer.id);
|
|
497
|
+
pending++;
|
|
498
|
+
db.setAgentStatus(reviewer.id, "working");
|
|
499
|
+
onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
|
|
500
|
+
fill(); // everyone else keeps going
|
|
501
|
+
const t0 = Date.now();
|
|
502
|
+
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt)), wt.path, {
|
|
503
|
+
signal,
|
|
504
|
+
timeoutMs: 90_000,
|
|
505
|
+
noTools: true, // the diff is in the prompt — no need to explore
|
|
506
|
+
quick: true, // 90s box: an adapter may use its fast model
|
|
507
|
+
});
|
|
508
|
+
const verdict = res.ok ? parseVerdict(res.text) : { approve: true, skipped: true, notes: "timed out" };
|
|
509
|
+
reserved.delete(reviewer.id);
|
|
510
|
+
db.setAgentStatus(reviewer.id, "idle");
|
|
511
|
+
pending--;
|
|
512
|
+
skippedReview = !!verdict.skipped;
|
|
513
|
+
onEvent({ type: "reviewed", agent: reviewer.id, author: w.id, task: task.id, title: task.title, ...verdict, ms: Date.now() - t0 });
|
|
514
|
+
if (!verdict.approve && !signal?.aborted) {
|
|
515
|
+
db.remember({ kind: "gotcha", body: `Review of "${task.title}" by ${reviewer.id}: ${verdict.notes}`, author: reviewer.id });
|
|
516
|
+
carry.set(task.id, totals(task.id, h, workMs));
|
|
517
|
+
reserved.delete(w.id);
|
|
518
|
+
// The author fixes it in the same worktree, with the review in hand.
|
|
519
|
+
start(w, task, `\n\nA reviewer (${reviewer.id}) asked for these changes before merge — fix them:\n${verdict.notes}`);
|
|
520
|
+
return fill();
|
|
521
|
+
}
|
|
522
|
+
reserved.delete(w.id);
|
|
523
|
+
}
|
|
524
|
+
let approved = true;
|
|
525
|
+
if (opts.approveMerge) {
|
|
526
|
+
pending++;
|
|
527
|
+
fill(); // keep the other agents busy while you decide
|
|
528
|
+
approved = await opts.approveMerge({ agent: w.id, title: task.title, diffstat: prep.diffstat });
|
|
529
|
+
pending--;
|
|
530
|
+
}
|
|
531
|
+
if (!approved)
|
|
532
|
+
fail(w, h, task.title, `you declined — work kept on branch ${h.branch}`, workMs);
|
|
533
|
+
else {
|
|
534
|
+
const base = headSha(repoRoot); // main before this lands — the handoff's diff base
|
|
535
|
+
let land = landMerge(repoRoot, wt);
|
|
536
|
+
if (!land.merged) {
|
|
537
|
+
// main moved while this waited (on review, or on you): rebase + re-test once, then land.
|
|
538
|
+
const again = prepareMerge(repoRoot, wt, testCmd(wt.path), h.excludes, `huddle(${w.id}): ${task.title}`);
|
|
539
|
+
if (again.conflict) {
|
|
540
|
+
resolveConflict(w, h, task.id, workMs);
|
|
541
|
+
return fill();
|
|
542
|
+
}
|
|
543
|
+
land = again.ok ? landMerge(repoRoot, wt) : { merged: false, reason: again.reason };
|
|
544
|
+
}
|
|
545
|
+
if (land.merged) {
|
|
546
|
+
db.setProgress(task.id, 100);
|
|
547
|
+
db.setTaskStatus(task.id, "merged");
|
|
548
|
+
removeWorktree(repoRoot, h.worktree);
|
|
549
|
+
if (w.remote)
|
|
550
|
+
deleteRemoteBranch(repoRoot, h.branch); // delivered branch is merged now
|
|
551
|
+
merged++;
|
|
552
|
+
// What dependents will be told: files, exported signatures, the author's notes.
|
|
553
|
+
recordHandoff(db, repoRoot, { taskId: task.id, agentId: w.id, title: task.title, base, since: firstStart.get(task.id) ?? h.startedAt });
|
|
554
|
+
const resolvedBy = resolving.get(task.id)?.by.at(-1);
|
|
555
|
+
const reason = prep.reason +
|
|
556
|
+
(reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}` : "") +
|
|
557
|
+
(resolvedBy ? ` · conflict resolved by ${resolvedBy}` : "");
|
|
558
|
+
onEvent({ type: "merged", agent: w.id, task: task.id, title: task.title, reason, ...totals(task.id, h, workMs) });
|
|
559
|
+
}
|
|
560
|
+
else
|
|
561
|
+
fail(w, h, task.title, land.reason, workMs);
|
|
562
|
+
}
|
|
563
|
+
fill();
|
|
564
|
+
};
|
|
565
|
+
/** Hand a task to a teammate's agent; their machine does the work, a branch comes back. */
|
|
566
|
+
const startRemote = async (w, task, extra) => {
|
|
567
|
+
const { branch, path: wtPath } = worktreeFor(repoRoot, w.id, task.id);
|
|
568
|
+
const h = {
|
|
569
|
+
agentId: w.id,
|
|
570
|
+
taskId: task.id,
|
|
571
|
+
worktree: wtPath,
|
|
572
|
+
branch,
|
|
573
|
+
cmd: [],
|
|
574
|
+
logPath: `(on ${w.remote.member}'s machine)`,
|
|
575
|
+
excludes: [],
|
|
576
|
+
startedAt: Date.now(),
|
|
577
|
+
meter: new UsageMeter(),
|
|
578
|
+
};
|
|
579
|
+
db.setTaskStatus(task.id, "in_progress");
|
|
580
|
+
db.setAgentStatus(w.id, "working");
|
|
581
|
+
running.set(w.id, h);
|
|
582
|
+
onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra, remote: w.remote.member });
|
|
583
|
+
const base = opts.publish?.();
|
|
584
|
+
if (!base)
|
|
585
|
+
return void finish(w, h, 1, "can't delegate: main isn't pushed to the shared remote (turn on `sync`)");
|
|
586
|
+
const accepted = await opts.bridge.assign(w.remote, { ...task, spec: task.spec + extra }, base, branch);
|
|
587
|
+
if (!accepted) {
|
|
588
|
+
running.delete(w.id);
|
|
589
|
+
db.setAgentStatus(w.id, "idle");
|
|
590
|
+
db.releaseTask(task.id);
|
|
591
|
+
unavailable.add(w.id);
|
|
592
|
+
onEvent({ type: "declined", agent: w.id, task: task.id, title: task.title });
|
|
593
|
+
return fill();
|
|
594
|
+
}
|
|
595
|
+
const d = await opts.bridge.delivered(task.id);
|
|
596
|
+
h.meter.add({ tokens: d.tokens, cached: d.cached, total: true });
|
|
597
|
+
if (d.ms)
|
|
598
|
+
h.startedAt = Date.now() - d.ms;
|
|
599
|
+
if (!d.ok)
|
|
600
|
+
return void finish(w, h, 1, d.reason ?? "teammate's agent failed");
|
|
601
|
+
const f = fetchBranch(repoRoot, branch);
|
|
602
|
+
if (!f.ok)
|
|
603
|
+
return void finish(w, h, 1, `couldn't fetch ${branch}: ${f.note}`);
|
|
604
|
+
createWorktree(repoRoot, w.id, task.id); // checks out the delivered branch
|
|
605
|
+
void finish(w, h, 0);
|
|
606
|
+
};
|
|
607
|
+
const start = (w, task, extra = "", takeoverFrom) => {
|
|
608
|
+
const first = !firstStart.has(task.id);
|
|
609
|
+
if (first)
|
|
610
|
+
firstStart.set(task.id, Date.now());
|
|
611
|
+
const part = first ? splitFrom.get(task.id) : undefined;
|
|
612
|
+
if (part && part.from !== w.id) {
|
|
613
|
+
// "cursor took part of codex's task": a huddle_split part, picked up by a free agent.
|
|
614
|
+
const reason = `split off “${part.parentTitle}”`;
|
|
615
|
+
db.publish("task.takeover", { task_id: task.id, agent_id: w.id, payload: { kind: "split", from: part.from, to: w.id, title: task.title, reason } });
|
|
616
|
+
onEvent({ type: "takeover", kind: "split", agent: w.id, from: part.from, task: task.id, title: task.title, reason, ms: 0, tokens: 0, cached: 0 });
|
|
617
|
+
}
|
|
618
|
+
// Upstream handoffs ride in the prompt — every round, since each launch is a fresh process.
|
|
619
|
+
const ho = handoffsFor(db, task.id);
|
|
620
|
+
if (first)
|
|
621
|
+
for (const h of ho.from) {
|
|
622
|
+
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
|
+
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
|
+
}
|
|
625
|
+
if (w.remote)
|
|
626
|
+
return void startRemote(w, { ...task, spec: task.spec + ho.text }, extra);
|
|
627
|
+
// Who else is mid-task right now, so this agent knows whom to tell (first round only;
|
|
628
|
+
// fill() claims a whole wave before launching any of it, so siblings see each other).
|
|
629
|
+
const along = first && !takeoverFrom ? alongsideBrief(db, task.id, w.id) : "";
|
|
630
|
+
// Every round of a task runs in its first worktree — a rescuer continues there too.
|
|
631
|
+
const prior = taskWt.get(task.id);
|
|
632
|
+
const launched = launchAgent({
|
|
633
|
+
repoRoot,
|
|
634
|
+
adapter: w.adapter,
|
|
635
|
+
agentId: w.id,
|
|
636
|
+
role: "worker",
|
|
637
|
+
taskId: task.id,
|
|
638
|
+
taskSpec: `${task.title}\n\n${task.spec}${ho.text}${along}${extra}`,
|
|
639
|
+
roomUrl,
|
|
640
|
+
roomSecret: opts.roomSecret,
|
|
641
|
+
approvals: opts.approvals,
|
|
642
|
+
worktree: prior ? { branch: prior.branch, path: prior.path } : undefined,
|
|
643
|
+
onTokens: (tokens) => onEvent({ type: "tokens", agent: w.id, task: task.id, tokens }),
|
|
644
|
+
});
|
|
645
|
+
// Every agent's Plug wiring that sat in this worktree stays out of the commit.
|
|
646
|
+
const h = { ...launched, excludes: [...new Set([...(prior?.excludes ?? []), ...launched.excludes])] };
|
|
647
|
+
taskWt.set(task.id, { branch: h.branch, path: h.worktree, excludes: h.excludes });
|
|
648
|
+
db.setTaskStatus(task.id, "in_progress");
|
|
649
|
+
db.setAgentStatus(w.id, "working", h.worktree);
|
|
650
|
+
running.set(w.id, h);
|
|
651
|
+
heard(w.id, h);
|
|
652
|
+
onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra && !takeoverFrom, ...(takeoverFrom ? { takeover: takeoverFrom } : {}) });
|
|
653
|
+
let exited = false; // 'error' and 'exit' can both fire
|
|
654
|
+
const once = (code) => {
|
|
655
|
+
if (exited)
|
|
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));
|
|
662
|
+
};
|
|
663
|
+
const fill = () => {
|
|
664
|
+
// Conflicts first: they hold finished work, and the author is usually free.
|
|
665
|
+
for (const c of conflicts.splice(0)) {
|
|
666
|
+
if (signal?.aborted) {
|
|
667
|
+
parkForYou(c, "stopped by you");
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const r = pickResolver(c.author, resolving.get(c.taskId)?.by ?? []);
|
|
671
|
+
if (r)
|
|
672
|
+
startResolve(r, c);
|
|
673
|
+
else
|
|
674
|
+
conflicts.push(c); // everyone's busy: the next agent to finish takes it
|
|
675
|
+
}
|
|
676
|
+
// Then takeovers: half-done work beats a fresh task.
|
|
677
|
+
for (const rc of rescues.splice(0)) {
|
|
678
|
+
const title = db.getTask(rc.taskId).title;
|
|
679
|
+
if (signal?.aborted) {
|
|
680
|
+
fail(rc.from, rc.h, title, `${rc.reason} (stopped before another agent could take it over)`, 0);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const r = pickRescuer(rc);
|
|
684
|
+
if (r)
|
|
685
|
+
startRescue(r, rc);
|
|
686
|
+
else
|
|
687
|
+
rescues.push(rc); // the next other agent to finish takes it
|
|
688
|
+
}
|
|
689
|
+
if (!signal?.aborted) {
|
|
690
|
+
// Fairness: the free agent that has taken the fewest tasks goes first
|
|
691
|
+
// (roster order breaks ties), so one agent doesn't take every task that
|
|
692
|
+
// unblocks just because it's listed first.
|
|
693
|
+
const free = all().map((w, i) => ({ w, i })).sort((a, b) => (taken.get(a.w.id) ?? 0) - (taken.get(b.w.id) ?? 0) || a.i - b.i);
|
|
694
|
+
// The whole wave is claimed first and launched after, so every task in
|
|
695
|
+
// it is told who else is starting alongside it (alongsideBrief).
|
|
696
|
+
const wave = [];
|
|
697
|
+
const picked = new Set();
|
|
698
|
+
// A task tagged "@<agent>" (a QA bug's fix, routed to whoever wrote
|
|
699
|
+
// those files) goes to that agent first when it's free; if it's busy
|
|
700
|
+
// or absent, the loop below lets any free agent take it.
|
|
701
|
+
for (const { w } of free) {
|
|
702
|
+
if (running.has(w.id) || reserved.has(w.id) || unavailable.has(w.id) || (w.remote && !opts.bridge))
|
|
703
|
+
continue;
|
|
704
|
+
const mine = db.nextBacklogTask([`@${w.id}`]);
|
|
705
|
+
if (!mine || !JSON.parse(mine.prefers).includes(`@${w.id}`))
|
|
706
|
+
continue;
|
|
707
|
+
register(w);
|
|
708
|
+
if (!db.claimTask(mine.id, w.id).ok)
|
|
709
|
+
continue;
|
|
710
|
+
taken.set(w.id, (taken.get(w.id) ?? 0) + 1);
|
|
711
|
+
picked.add(w.id);
|
|
712
|
+
wave.push({ w, task: mine });
|
|
713
|
+
}
|
|
714
|
+
for (const { w } of free) {
|
|
715
|
+
if (picked.has(w.id) || running.has(w.id) || reserved.has(w.id) || unavailable.has(w.id))
|
|
716
|
+
continue;
|
|
717
|
+
if (w.remote && !opts.bridge)
|
|
718
|
+
continue;
|
|
719
|
+
register(w);
|
|
720
|
+
const next = db.nextBacklogTask(prefersOf(w));
|
|
721
|
+
if (!next)
|
|
722
|
+
continue;
|
|
723
|
+
if (!db.claimTask(next.id, w.id).ok)
|
|
724
|
+
continue;
|
|
725
|
+
taken.set(w.id, (taken.get(w.id) ?? 0) + 1);
|
|
726
|
+
picked.add(w.id);
|
|
727
|
+
wave.push({ w, task: next });
|
|
728
|
+
}
|
|
729
|
+
for (const { w, task } of wave)
|
|
730
|
+
start(w, task);
|
|
731
|
+
}
|
|
732
|
+
offerHelp();
|
|
733
|
+
if (running.size === 0 && pending === 0) {
|
|
734
|
+
// Nobody is running yet a conflict still waits: no agent on this machine can resolve it.
|
|
735
|
+
for (const c of conflicts.splice(0))
|
|
736
|
+
parkForYou(c, "no agent on this machine could take the resolution");
|
|
737
|
+
for (const rc of rescues.splice(0))
|
|
738
|
+
fail(rc.from, rc.h, db.getTask(rc.taskId).title, `${rc.reason} (no other agent could take it over)`, 0);
|
|
739
|
+
const blocked = db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
|
|
740
|
+
if (blocked.length && !signal?.aborted)
|
|
741
|
+
onEvent({ type: "stuck", blocked });
|
|
742
|
+
done();
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
fill();
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
//# sourceMappingURL=scheduler.js.map
|