huddle-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -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 +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The planner turns one goal into a task graph sized to the goal and to the
|
|
2
|
+
// crew. An agent (read-only, in the repo) writes the plan; if that fails for
|
|
3
|
+
// any reason we fall back to a small deterministic template so a run never
|
|
4
|
+
// blocks on planning.
|
|
5
|
+
import { brainFrom, repoContext } from "./brain.js";
|
|
6
|
+
import { linkPlan, parseContextIds } from "../room/context.js";
|
|
7
|
+
import { carryGoal } from "./goal.js";
|
|
8
|
+
/**
|
|
9
|
+
* Fallback when no brain can plan: the whole goal as ONE task. (It used to be
|
|
10
|
+
* a scaffold → UI → logic → tests → review spine; seen live, a planner hiccup
|
|
11
|
+
* turned "add two shape modules" into five tasks, and an agent built a UI
|
|
12
|
+
* nobody asked for. One honest task beats an invented plan.)
|
|
13
|
+
*/
|
|
14
|
+
export function templatePlan(goal) {
|
|
15
|
+
const title = goal.length > 72 ? goal.slice(0, 71).replace(/\s+\S*$/, "") + "…" : goal;
|
|
16
|
+
return [{ title, spec: goal, prefers: [] }];
|
|
17
|
+
}
|
|
18
|
+
const TAGS = ["frontend", "design", "product", "refactor", "systems", "algorithms", "backend", "research", "breadth", "docs", "data", "quickfix"];
|
|
19
|
+
/**
|
|
20
|
+
* How big the plan should be. A change: as few tasks as it takes. A project
|
|
21
|
+
* (a big goal, see goalSize): enough tasks to keep every agent busy for the
|
|
22
|
+
* whole run. Found live on the capstone: with only "size the plan to the
|
|
23
|
+
* goal" and "up to <crew> tasks at once", the council's plan for a whole app
|
|
24
|
+
* was 5 big tasks, 2 of them startable, so one of three agents sat idle at the
|
|
25
|
+
* start and nobody could merge more than two.
|
|
26
|
+
*/
|
|
27
|
+
export function sizeRules(crew, project) {
|
|
28
|
+
if (!project)
|
|
29
|
+
return `- Size the plan to the goal. A one-line change is ONE task. Never pad.
|
|
30
|
+
- Maximize parallelism: as many tasks as possible (up to ${crew}) should be startable immediately, with no depends_on.`;
|
|
31
|
+
const lo = Math.max(2, crew * 2);
|
|
32
|
+
const hi = Math.min(MAX_TASKS, Math.max(lo + 2, crew * 4));
|
|
33
|
+
return `- This is a whole project for ${crew} agent${crew === 1 ? "" : "s"}, not a change: plan ${lo}-${hi} tasks, so every agent keeps working for the whole run and finishes 2+ tasks. Each task is one coherent module or feature with its own tests, small enough for one agent in about 10 minutes. Don't bundle two modules into one task, and don't pad.
|
|
34
|
+
- At least ${crew} tasks must be startable immediately, with no depends_on: a module whose contract is written into the specs (exports, data shapes, exact messages) can be built and tested in parallel with its consumers.`;
|
|
35
|
+
}
|
|
36
|
+
export function plannerPrompt(goal, crew, context, council, opts = {}) {
|
|
37
|
+
return `You are the planner for Huddle, which runs several AI coding agents on one repo at the same time.
|
|
38
|
+
Each task runs in its own git worktree; finished tasks are tested and merged into main.
|
|
39
|
+
|
|
40
|
+
GOAL: ${goal}
|
|
41
|
+
|
|
42
|
+
CREW (${crew.length} agents, all available now): ${[...new Set(crew.map((c) => c.id.split("#")[0]))]
|
|
43
|
+
.map((id) => {
|
|
44
|
+
const n = crew.filter((c) => c.id.split("#")[0] === id).length;
|
|
45
|
+
const a = crew.find((c) => c.id.split("#")[0] === id);
|
|
46
|
+
return `${id}${n > 1 ? ` ×${n}` : ""} (good at: ${a.prefers.join(", ")})`;
|
|
47
|
+
})
|
|
48
|
+
.join("; ")}
|
|
49
|
+
|
|
50
|
+
REPO SNAPSHOT:
|
|
51
|
+
${context}
|
|
52
|
+
|
|
53
|
+
${council ? `${council.notes}
|
|
54
|
+
|
|
55
|
+
` : ""}Plan from this snapshot${council ? " and the council's proposals and cross-reviews: build on the ideas teammates ADOPTed, fix every FLAW they found, and cover what they said was missing" : ""}. Split the goal into tasks. Rules:
|
|
56
|
+
${sizeRules(crew.length, opts.project ?? !!council)}
|
|
57
|
+
- Avoid a "scaffold first" bottleneck: if tasks share an interface, define it in each task's spec (exact function names, file paths, data shapes) so they can be built in parallel against that contract.
|
|
58
|
+
- Parallel tasks should own disjoint files. List each task's files — every file it will edit, including shared ones it must touch (an index/registry/router that re-exports modules, package.json, a config). If two tasks must edit the same file, list it on both: Huddle then runs them one after the other, or merges both edits safely.
|
|
59
|
+
- Never write "do not edit/touch <file>" in a spec to keep tasks apart: if the repo's tests or structure need a shared file changed (e.g. a test that every module is re-exported from an index), the task needs it — an agent that obeys the ban leaves the tests red and the task fails. List the file on each task that needs it instead.
|
|
60
|
+
- Use depends_on only when a task truly needs another task's merged code.
|
|
61
|
+
- Each spec must be self-contained: what to build, where, the contract, and how to verify it.
|
|
62
|
+
- Carry the goal's exact requirements VERBATIM into the spec of every task that implements them: each exact output line, error message, format, number and edge-case rule (quoting, blank lines, bad rows, defaults, exit codes, stdout vs stderr), word for word — never paraphrased or shortened. Never invent a different message or output for a case the goal already gives one: a missing required input gets the goal's own error (e.g. "<field> is required"), not a generic usage error.
|
|
63
|
+
- Consistency: different agents write each task, so start EVERY spec with the same one-line "Conventions:" (language/module system, formatting such as semicolons and quotes, test runner and test file naming). Take them from the existing code; if the repo is empty, choose them once and repeat them verbatim.
|
|
64
|
+
- prefers: 1-3 tags from: ${TAGS.join(", ")}.
|
|
65
|
+
${council ? `- contract: the exact names tasks share — files/modules and the task that owns each, exported functions with signatures, data types with field names, CLI flags, routes, error strings (the goal's exact strings, copied), test helpers. Markdown, under 3000 characters. Every spec uses these names verbatim. The contract adds names the goal leaves open; it never overrides what the goal states.
|
|
66
|
+
` : ""}
|
|
67
|
+
You have no tools in this step and cannot read files: the snapshot above is everything. Do not try to call a tool — plan from what you see.
|
|
68
|
+
Reply with ONLY this JSON, no prose, no code fences:
|
|
69
|
+
${council
|
|
70
|
+
? `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0],"from":"the ONE agent id whose idea this task mainly implements","with":["other agent ids whose ideas it also uses"]}],"decisions":["a key design decision every agent must follow (max 6)"],"risks":[{"risk":"a risk or flaw to watch while building (max 6)","by":"agent id that flagged it"}],"contract":"markdown"}
|
|
71
|
+
"from" and "with" use only these ids: ${council.members.join(", ")}.`
|
|
72
|
+
: `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0]}]}`}`;
|
|
73
|
+
}
|
|
74
|
+
/** Candidate JSON bodies in a reply: fenced blocks first, then brace-to-brace. */
|
|
75
|
+
export function jsonCandidates(text) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const m of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g))
|
|
78
|
+
out.push(m[1]);
|
|
79
|
+
const end = text.lastIndexOf("}");
|
|
80
|
+
for (let i = text.indexOf("{"); i >= 0 && i < end; i = text.indexOf("{", i + 1))
|
|
81
|
+
out.push(text.slice(i, end + 1));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** The most tasks a plan may have; a longer reply is not a plan. */
|
|
85
|
+
export const MAX_TASKS = 20;
|
|
86
|
+
/** A council task's idea credit: "from" is one primary id (or, from older prompts, a list), "with" the contributors. */
|
|
87
|
+
function ideaFrom(t) {
|
|
88
|
+
const primary = typeof t.from === "string" ? [t.from] : Array.isArray(t.from) ? t.from.map(String) : [];
|
|
89
|
+
const from = [...new Set([...primary, ...(Array.isArray(t.with) ? t.with.map(String) : [])].map((x) => x.trim()).filter(Boolean))];
|
|
90
|
+
return from.length ? { from } : {};
|
|
91
|
+
}
|
|
92
|
+
/** Pull a {"tasks": [...]} plan out of a model's reply, however it was wrapped. */
|
|
93
|
+
export function parsePlan(text) {
|
|
94
|
+
for (const body of jsonCandidates(text)) {
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.parse(body);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// A from-scratch project (the capstone's brief) honestly needs 10-14 tasks; 12 used to throw such a plan away.
|
|
103
|
+
if (!Array.isArray(raw?.tasks) || raw.tasks.length === 0 || raw.tasks.length > MAX_TASKS)
|
|
104
|
+
continue;
|
|
105
|
+
const titles = raw.tasks.map((t) => String(t?.title ?? "").trim());
|
|
106
|
+
if (titles.some((t) => !t))
|
|
107
|
+
continue;
|
|
108
|
+
// depends_on may come back as indices (0- or 1-based is ambiguous, we use 0) or as titles.
|
|
109
|
+
const toIndex = (d) => (typeof d === "number" ? d : titles.indexOf(String(d).trim()));
|
|
110
|
+
return raw.tasks.map((t, i) => ({
|
|
111
|
+
title: titles[i].slice(0, 120),
|
|
112
|
+
spec: String(t.spec ?? ""),
|
|
113
|
+
files: Array.isArray(t.files) ? t.files.map(String) : [],
|
|
114
|
+
prefers: Array.isArray(t.prefers) ? t.prefers.map(String) : [],
|
|
115
|
+
...ideaFrom(t),
|
|
116
|
+
...(parseContextIds(t.context).length ? { context: parseContextIds(t.context) } : {}),
|
|
117
|
+
// only backward edges → guaranteed acyclic
|
|
118
|
+
depends_on: (Array.isArray(t.depends_on) ? t.depends_on : []).map(toIndex).filter((d) => Number.isInteger(d) && d >= 0 && d < i),
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
export async function planGoal(goal, crew, repoRoot, opts = {}) {
|
|
124
|
+
const brain = opts.brain ?? brainFrom("claude");
|
|
125
|
+
if (!brain)
|
|
126
|
+
return { tasks: templatePlan(goal), by: "template", note: "no brain available" };
|
|
127
|
+
const prompt = plannerPrompt(goal, crew, repoContext(repoRoot), undefined, { project: opts.project });
|
|
128
|
+
let ms = 0;
|
|
129
|
+
for (let attempt = 0; attempt < 2 && !opts.signal?.aborted; attempt++) {
|
|
130
|
+
const res = await brain.think(prompt, repoRoot, {
|
|
131
|
+
signal: opts.signal,
|
|
132
|
+
// A plan takes ~6–15s on a cloud model; a call that hangs is retried
|
|
133
|
+
// instead of eating the whole budget (seen live: 4m30s, then the
|
|
134
|
+
// fallback). Local models and Opus (the default brain) are slower.
|
|
135
|
+
timeoutMs: brain.spec.startsWith("ollama") || brain.spec === "claude" || brain.spec.endsWith(":opus") ? 4 * 60_000 : 2 * 60_000,
|
|
136
|
+
json: true,
|
|
137
|
+
// The snapshot is in the prompt; exploring just costs time. If that
|
|
138
|
+
// first answer isn't a plan, the retry may read the repo (read-only).
|
|
139
|
+
selfContained: attempt === 0,
|
|
140
|
+
});
|
|
141
|
+
ms += res.ms;
|
|
142
|
+
const tasks = res.ok ? parsePlan(res.text) : null;
|
|
143
|
+
if (tasks && opts.project && tasks.length > 1) {
|
|
144
|
+
// A project's specs carry the goal's exact requirements too, as the council's do (goal.ts).
|
|
145
|
+
const carried = carryGoal(goal, tasks);
|
|
146
|
+
return { tasks: carried.tasks, by: brain.label, ms, tokens: res.tokens, goalCheck: carried.check };
|
|
147
|
+
}
|
|
148
|
+
if (tasks)
|
|
149
|
+
return { tasks, by: brain.label, ms, tokens: res.tokens };
|
|
150
|
+
}
|
|
151
|
+
return { tasks: templatePlan(goal), by: "template", note: `${brain.label} didn't return a usable plan`, ms };
|
|
152
|
+
}
|
|
153
|
+
/** Materialize a plan into the Room, wiring up dependency ids correctly. */
|
|
154
|
+
export function seedBoard(db, goal, planned) {
|
|
155
|
+
db.createSession(goal);
|
|
156
|
+
const ids = [];
|
|
157
|
+
for (const p of planned) {
|
|
158
|
+
const deps = (p.depends_on ?? []).map((i) => ids[i]).filter(Boolean);
|
|
159
|
+
// Found live (registry eval): "Files you own" read as a fence — two agents
|
|
160
|
+
// left npm test red rather than add their line to an unlisted src/index.js.
|
|
161
|
+
const spec = p.files?.length
|
|
162
|
+
? `${p.spec}\n\nPlanned files: ${p.files.join(", ")} (if the work needs another file too, announce it with huddle_announce_plan and edit it — a passing test suite beats any "don't touch" above)`
|
|
163
|
+
: p.spec;
|
|
164
|
+
// files also go to the scheduler: two tasks that share one never run at once.
|
|
165
|
+
const task = db.addTask({ title: p.title, spec, prefers: p.prefers, depends_on: deps, files: p.files });
|
|
166
|
+
ids.push(task.id);
|
|
167
|
+
}
|
|
168
|
+
// Shared-screen items the planner tied to each task ride in that task's prompt.
|
|
169
|
+
linkPlan(db, ids, planned);
|
|
170
|
+
return ids;
|
|
171
|
+
}
|
|
172
|
+
/** "(1) … (2) …", "1. … 2. …" or "three tasks": the user already split the work. */
|
|
173
|
+
const LISTS_TASKS = /\b(one|two|three|four|five|six|\d+) (separate |independent )?tasks?\b|\btask \d\b/i;
|
|
174
|
+
export function listsOwnTasks(goal) {
|
|
175
|
+
const numbered = (goal.match(/\(\d+\)|(^|\n)\s*\d+[.)]\s/g) ?? []).length;
|
|
176
|
+
return numbered >= 2 || LISTS_TASKS.test(goal);
|
|
177
|
+
}
|
|
178
|
+
const FROM_SCRATCH = /\bfrom (scratch|the ground up|zero)\b|\bgreenfield\b|\bnew (project|app|application|product)\b|\bend[- ]to[- ]end\b|\bfull[- ]stack\b|\b(an? )?mvp\b|\bwhole (app|project|product|system)\b/i;
|
|
179
|
+
const NOUNS = "app|application|game|website|web ?app|site|service|backend|frontend|api|cli|tool|platform|dashboard|project|system|clone|bot|server|compiler|interpreter|engine";
|
|
180
|
+
const BUILD_A = new RegExp(`\\b(?:(?:build|develop)\\s+(?:me\\s+)?(?:an?|the)|(?:create|make|write)\\s+(?:me\\s+)?an?)\\s+(?:[\\w-]+\\s+){0,4}?(?:${NOUNS})\\b`, "i");
|
|
181
|
+
/**
|
|
182
|
+
* Is this a big goal — a project, not a change? Big goals get the heavier team
|
|
183
|
+
* phases (a design council before, a QA round after); small goals keep the
|
|
184
|
+
* fast path. ONE heuristic so the phases agree. Big: "from scratch / end to
|
|
185
|
+
* end / full-stack / MVP", "build a <something> app/game/site/API/CLI…", 5+
|
|
186
|
+
* listed items, or a plan of 5+ tasks. Then, unless the goal already spells
|
|
187
|
+
* out its own few tasks (the eval goals: "Three tasks. (1) … (2) …"), a long
|
|
188
|
+
* goal (600+ chars) or a real goal (120+ chars) in a (nearly) empty repo.
|
|
189
|
+
*/
|
|
190
|
+
export function goalSize(goal, opts = {}) {
|
|
191
|
+
if (FROM_SCRATCH.test(goal))
|
|
192
|
+
return { big: true, why: "a from-scratch project" };
|
|
193
|
+
if (BUILD_A.test(goal))
|
|
194
|
+
return { big: true, why: "a new project to build" };
|
|
195
|
+
const items = goal.split("\n").filter((l) => /^\s*([-*•]|\d+[.)])\s+\S/.test(l)).length;
|
|
196
|
+
const inline = (goal.match(/\(\d+\)/g) ?? []).length;
|
|
197
|
+
if (Math.max(items, inline) >= 5)
|
|
198
|
+
return { big: true, why: "a spec of 5+ items" };
|
|
199
|
+
if ((opts.tasks ?? 0) >= 5)
|
|
200
|
+
return { big: true, why: `a plan of ${opts.tasks} tasks` };
|
|
201
|
+
if (listsOwnTasks(goal))
|
|
202
|
+
return { big: false, why: "the goal already lists its tasks" };
|
|
203
|
+
if (goal.length >= 600)
|
|
204
|
+
return { big: true, why: "a long goal" };
|
|
205
|
+
if ((opts.repoFiles ?? Infinity) <= 3 && goal.length >= 120)
|
|
206
|
+
return { big: true, why: "a new project in an empty repo" };
|
|
207
|
+
return { big: false, why: "a small goal" };
|
|
208
|
+
}
|
|
209
|
+
export function isBigGoal(goal, opts = {}) {
|
|
210
|
+
return goalSize(goal, opts).big;
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=planner.js.map
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// QA: the team re-checks its own build. After every planned task has merged,
|
|
2
|
+
// an agent that did NOT write the code (rotating between rounds) checks the
|
|
3
|
+
// integrated main in a fresh worktree: runs the whole test suite, uses the
|
|
4
|
+
// thing the way the goal says a user would, and reads the diff for flaws.
|
|
5
|
+
// Every concrete bug it files with huddle_report_bug becomes a fix task on
|
|
6
|
+
// the board, routed to the agent that wrote those files ("@<agent>" in its
|
|
7
|
+
// prefers — the scheduler gives it to them first, else to anyone free), with
|
|
8
|
+
// the repro (and a regression test, when the QA agent wrote one) in the spec.
|
|
9
|
+
// Fixes go through the normal pipeline: worktree, tests, cross-review, merge.
|
|
10
|
+
// Then QA runs again — up to `rounds` times, or until a round comes back clean.
|
|
11
|
+
//
|
|
12
|
+
// QA agents never land anything themselves: their worktree is thrown away.
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { launchAgent, stopChild } from "./launcher.js";
|
|
17
|
+
import { createWorktree, removeWorktree } from "../sandbox/worktree.js";
|
|
18
|
+
import { onFinished } from "../platform.js";
|
|
19
|
+
import { isBigGoal } from "./planner.js";
|
|
20
|
+
import { acceptanceList } from "./goal.js";
|
|
21
|
+
export const QA_ROUNDS = 2;
|
|
22
|
+
/** Bugs one round may file — QA should report the serious ones, not a wishlist. */
|
|
23
|
+
export const QA_MAX_BUGS = 6;
|
|
24
|
+
const QA_TIMEOUT_MS = 15 * 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* A first clean round ends QA only if its check took at least this long
|
|
27
|
+
* (HUDDLE_QA_MIN_CLEAN_S overrides); a quicker one gets a second opinion from
|
|
28
|
+
* another agent. Found live (capstone round 5): cursor's round-1 check came
|
|
29
|
+
* back clean in 90s, QA stopped there, and 2 of the hidden checks failed
|
|
30
|
+
* on bugs a goal-by-goal check would have hit at once.
|
|
31
|
+
*/
|
|
32
|
+
export function qaMinCleanMs() {
|
|
33
|
+
const s = Number(process.env.HUDDLE_QA_MIN_CLEAN_S);
|
|
34
|
+
return Number.isFinite(s) && s >= 0 ? s * 1000 : 5 * 60_000;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* QA on or off for this goal: an explicit --qa / --no-qa wins; otherwise on
|
|
38
|
+
* for big goals (the same heuristic as the design council), off for small
|
|
39
|
+
* ones, and never in plan-only mode.
|
|
40
|
+
*/
|
|
41
|
+
export function shouldQa(goal, o = {}) {
|
|
42
|
+
if (o.planOnly)
|
|
43
|
+
return false;
|
|
44
|
+
if (o.flag !== undefined)
|
|
45
|
+
return o.flag;
|
|
46
|
+
return isBigGoal(goal, { tasks: o.tasks, repoFiles: o.repoFiles });
|
|
47
|
+
}
|
|
48
|
+
function git(cwd, args) {
|
|
49
|
+
try {
|
|
50
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024 }).trim();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
|
|
57
|
+
const str = (v, n) => cap(String(v ?? "").trim(), n);
|
|
58
|
+
/** A title cut at a word, not mid-word (found live: "… and counts i…"). */
|
|
59
|
+
const capWords = (s, n) => {
|
|
60
|
+
if (s.length <= n)
|
|
61
|
+
return s;
|
|
62
|
+
const cut = s.slice(0, n);
|
|
63
|
+
const sp = cut.lastIndexOf(" ");
|
|
64
|
+
return (sp > n * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s,;:—-]+$/, "") + "…";
|
|
65
|
+
};
|
|
66
|
+
/** Who wrote each file since `since`: the newest `huddle(<agent>): …` commit that touched it. */
|
|
67
|
+
export function authorsOf(repoRoot, since, files) {
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const subjects = git(repoRoot, ["log", "--format=%s", `${since}..HEAD`, "--", f]).split(/\r?\n/);
|
|
71
|
+
const who = subjects.map((s) => /^huddle\(([^)]+)\): /.exec(s)?.[1]).find(Boolean);
|
|
72
|
+
if (who)
|
|
73
|
+
out.set(f, who);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Every agent with a merged commit since `since`. */
|
|
78
|
+
function authorsSince(repoRoot, since) {
|
|
79
|
+
const n = new Map();
|
|
80
|
+
for (const s of git(repoRoot, ["log", "--format=%s", `${since}..HEAD`]).split(/\r?\n/)) {
|
|
81
|
+
const who = /^huddle\(([^)]+)\): /.exec(s)?.[1];
|
|
82
|
+
if (who)
|
|
83
|
+
n.set(who, (n.get(who) ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
const STOP = new Set(["the", "a", "an", "is", "are", "of", "in", "on", "to", "for", "and", "or", "with", "when", "it", "its", "not", "no", "does", "doesn't", "dont", "don't", "be", "by", "at", "fails", "fail", "bug", "broken", "wrong"]);
|
|
88
|
+
const words = (s) => new Set(s.toLowerCase().replace(/[^\w\s.-]/g, " ").split(/\s+/).filter((w) => w.length > 1 && !STOP.has(w)));
|
|
89
|
+
/** Same bug, reported twice? Close titles in the same place (or no place given). Same words in different files = two bugs. */
|
|
90
|
+
export function sameBug(a, b) {
|
|
91
|
+
const x = words(a.title);
|
|
92
|
+
const y = words(b.title);
|
|
93
|
+
const inter = [...x].filter((w) => y.has(w)).length;
|
|
94
|
+
const jac = inter / Math.max(1, new Set([...x, ...y]).size);
|
|
95
|
+
const sharedFiles = a.files.some((f) => b.files.includes(f));
|
|
96
|
+
const noPlace = !a.files.length || !b.files.length;
|
|
97
|
+
return jac >= 0.4 && (sharedFiles || noPlace);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Task id -> who cross-reviewed it (a review that ran, not one skipped on a
|
|
101
|
+
* time-out), from the scheduler's run.reviewed events. Found live (capstone
|
|
102
|
+
* round 3): 4 of 10 tasks merged unreviewed because no agent was free at the
|
|
103
|
+
* time, and QA's brief didn't say which — so QA read all of it equally.
|
|
104
|
+
*/
|
|
105
|
+
function reviewersOf(db) {
|
|
106
|
+
const out = new Map();
|
|
107
|
+
for (const ev of db.eventsOfType(["run.reviewed"], 0, 1000)) {
|
|
108
|
+
try {
|
|
109
|
+
const p = JSON.parse(ev.payload || "{}");
|
|
110
|
+
const task = p.task ?? ev.task_id;
|
|
111
|
+
if (task && p.agent && !p.skipped)
|
|
112
|
+
out.set(task, p.agent);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* a malformed event never breaks QA */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
function qaPrompt(o) {
|
|
121
|
+
return `QA CHECK — round ${o.round}. This task is a check, not a change: nothing you edit here lands.
|
|
122
|
+
The team just built this goal, and every planned task has merged into main:
|
|
123
|
+
GOAL: ${o.goal}
|
|
124
|
+
|
|
125
|
+
Your current directory is a fresh checkout of the integrated main.${o.author ? "" : " You did NOT write this code — look at it with fresh eyes."}
|
|
126
|
+
|
|
127
|
+
WHAT WAS MERGED (task · agent):
|
|
128
|
+
${o.tasks || "(none)"}
|
|
129
|
+
|
|
130
|
+
FILES CHANGED:
|
|
131
|
+
${o.stat || "(none)"}
|
|
132
|
+
|
|
133
|
+
THE DIFF (capped):
|
|
134
|
+
${o.diff || "(empty)"}
|
|
135
|
+
${o.checklist ? `\nACCEPTANCE CHECKLIST — the GOAL's requirements, one by one:\n${o.checklist}\n` : ""}${o.earlier.length ? `\nALREADY REPORTED in earlier rounds (their fixes merged or are in flight) — report one again only if it is STILL broken:\n${o.earlier.map((b) => `- ${b.title}${b.files.length ? ` (${b.files.join(", ")})` : ""}`).join("\n")}\n` : ""}
|
|
136
|
+
The GOAL is the spec you check against. The team's contract (HUDDLE_CONTRACT.md), README, task specs and tests are NOT: where any of them says something the goal doesn't, the goal wins, and code that follows them instead is a bug (e.g. a generic "invalid arguments" error where the goal gives an exact message for that case). Passing tests prove only what their authors thought of.
|
|
137
|
+
|
|
138
|
+
DO, in order (huddle_progress after each step):
|
|
139
|
+
1. Run the full test suite: ${o.testCmd ? `\`${o.testCmd}\`` : "(no test command detected — look for one, or skip)"}.
|
|
140
|
+
2. Go through the ACCEPTANCE CHECKLIST item by item: run the app / CLI / API the way a user would and compare the output with the goal byte for byte. For each item use the smallest input that triggers it, and its edge cases:
|
|
141
|
+
- every exact error message: trigger it with the input left out entirely (no argument, no flag, no field) AND given but empty or invalid; check the stream (stdout vs stderr), the exit code and the HTTP status
|
|
142
|
+
- every parser / import rule: special characters inside a quoted field AND inside an unquoted one, escapes, blank lines, extra or missing fields, rows that must be skipped, and the exact counts
|
|
143
|
+
- every exact output format: spacing, ordering, the empty case, the final newline
|
|
144
|
+
3. Read the diff for flaws: bugs, modules from different agents that don't fit together, missing requirements, crashes.
|
|
145
|
+
Start with the tasks marked NOT reviewed above: nobody but their author has read that code.
|
|
146
|
+
4. For EACH concrete, reproducible defect: huddle_report_bug(title, repro, expected, actual, files).
|
|
147
|
+
- When a test can show it, write a regression test in this repo's test style that FAILS now (and will pass once fixed),
|
|
148
|
+
then pass its path as test_file — the fix task gets it. Don't fix the bug yourself.
|
|
149
|
+
- Only real defects, at most ${o.maxBugs}, most serious first. No style nits, no "nice to have".
|
|
150
|
+
5. huddle_done(task_id, summary): which checklist items you ran, and "N bugs filed" or "clean".
|
|
151
|
+
Don't commit. If everything works, say so — but only after every checklist item was run.`;
|
|
152
|
+
}
|
|
153
|
+
export async function runQa(o) {
|
|
154
|
+
const { db, room, repoRoot, signal, onEvent } = o;
|
|
155
|
+
const rounds = o.rounds ?? QA_ROUNDS;
|
|
156
|
+
const maxBugs = o.maxBugs ?? QA_MAX_BUGS;
|
|
157
|
+
const result = { rounds: 0, bugs: 0, fixed: 0, failed: 0, clean: false, stopped: false };
|
|
158
|
+
const filed = [];
|
|
159
|
+
const used = new Map(); // QA agent -> rounds it checked (rotation)
|
|
160
|
+
const qaTasks = new Set(); // QA's own board tasks, never fix tasks
|
|
161
|
+
const local = o.workers.filter((w) => w.adapter);
|
|
162
|
+
if (!local.length) {
|
|
163
|
+
onEvent({ type: "skipped", reason: "no agent on this machine can run QA" });
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
// Who checks this round: someone who didn't write the code, rotating; an
|
|
167
|
+
// author only when everyone wrote some (fewest commits first).
|
|
168
|
+
// After a quick clean round, the second opinion comes from someone else (when there is anyone).
|
|
169
|
+
let secondOpinion = new Set();
|
|
170
|
+
const pick = () => {
|
|
171
|
+
const wrote = authorsSince(repoRoot, o.startHead);
|
|
172
|
+
const others = local.filter((w) => !secondOpinion.has(w.id));
|
|
173
|
+
return (others.length ? others : local)
|
|
174
|
+
.map((w, i) => ({ w, i, author: wrote.has(w.id), n: wrote.get(w.id) ?? 0, u: used.get(w.id) ?? 0 }))
|
|
175
|
+
.sort((a, b) => Number(a.author) - Number(b.author) || a.u - b.u || a.n - b.n || a.i - b.i)
|
|
176
|
+
.slice(0, Math.max(1, o.perRound ?? 1))
|
|
177
|
+
.map(({ w, author }) => ({ w, author }));
|
|
178
|
+
};
|
|
179
|
+
const worktrees = new Map(); // QA agent -> its worktree (to read a test_file)
|
|
180
|
+
const checklist = acceptanceList(o.goal);
|
|
181
|
+
const minCleanMs = o.minCleanMs ?? qaMinCleanMs();
|
|
182
|
+
let cleanRounds = 0;
|
|
183
|
+
let slowest = 0; // the longest finished check in this round
|
|
184
|
+
let round = 0;
|
|
185
|
+
let roundBugs = 0;
|
|
186
|
+
let finished = 0; // checks in this round that ran to the end
|
|
187
|
+
/** huddle_report_bug while a round runs: validate, dedupe, cap, file a routed fix task. */
|
|
188
|
+
const onBug = (raw, from) => {
|
|
189
|
+
const title = capWords(String(raw.title ?? "").trim().replace(/\s+/g, " "), 120);
|
|
190
|
+
const repro = str(raw.repro, 2000);
|
|
191
|
+
if (!title || !repro)
|
|
192
|
+
return { ok: false, error: "title and repro are required — exact steps or a command that shows the bug" };
|
|
193
|
+
const files = (Array.isArray(raw.files) ? raw.files : []).map((f) => str(f, 300).replace(/^\.\//, "")).filter(Boolean).slice(0, 20);
|
|
194
|
+
const me = { title, files };
|
|
195
|
+
// Same as one already filed: in this round, or an earlier one whose fix hasn't landed.
|
|
196
|
+
const dup = filed.find((b) => sameBug(b, me) && (b.round === round || db.getTask(b.task)?.status !== "merged"));
|
|
197
|
+
if (dup) {
|
|
198
|
+
db.publish("qa.duplicate", { agent_id: from, task_id: dup.task, payload: { round, by: from, title, of: dup.task } });
|
|
199
|
+
onEvent({ type: "duplicate", agent: from, round, title, of: dup.title });
|
|
200
|
+
return { ok: true, duplicate_of: dup.task, note: `Already filed as "${dup.title}" — not filed again.` };
|
|
201
|
+
}
|
|
202
|
+
if (roundBugs >= maxBugs)
|
|
203
|
+
return { ok: false, error: `this round already filed ${maxBugs} bugs (the cap) — finish with huddle_done` };
|
|
204
|
+
const reopened = filed.find((b) => sameBug(b, me));
|
|
205
|
+
// Regression test: the code, or the file the QA agent wrote in its worktree.
|
|
206
|
+
let testFile = raw.test_file ? str(raw.test_file, 300).replace(/^\.\//, "") : "";
|
|
207
|
+
let testCode = raw.test_code ? String(raw.test_code).slice(0, 20_000) : "";
|
|
208
|
+
const wt = worktrees.get(from);
|
|
209
|
+
if (testFile && !testCode && wt) {
|
|
210
|
+
const abs = path.resolve(wt, testFile);
|
|
211
|
+
if (abs.startsWith(wt + path.sep) && fs.existsSync(abs))
|
|
212
|
+
testCode = fs.readFileSync(abs, "utf8").slice(0, 20_000);
|
|
213
|
+
}
|
|
214
|
+
if (testCode && !testFile)
|
|
215
|
+
testFile = "(pick a path in this repo's test style)";
|
|
216
|
+
// Route to the author of most of these files; nobody known = any free agent.
|
|
217
|
+
const counts = new Map();
|
|
218
|
+
for (const a of authorsOf(repoRoot, o.startHead, files).values())
|
|
219
|
+
counts.set(a, (counts.get(a) ?? 0) + 1);
|
|
220
|
+
const assignee = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
221
|
+
const spec = [
|
|
222
|
+
`Bug found by ${from} in QA round ${round} of the goal: ${cap(o.goal.replace(/\s+/g, " "), 300)}`,
|
|
223
|
+
reopened ? `(Reported again after the earlier fix "${reopened.title}" merged — it is still broken.)` : "",
|
|
224
|
+
`REPRO: ${repro}`,
|
|
225
|
+
`EXPECTED: ${str(raw.expected, 1000)}`,
|
|
226
|
+
`ACTUAL: ${str(raw.actual, 1500)}`,
|
|
227
|
+
files.length ? `WHERE: ${files.join(", ")}` : "",
|
|
228
|
+
testCode
|
|
229
|
+
? `REGRESSION TEST (from QA — it fails on main today): add it as ${testFile} first, then fix the code so it and the whole suite pass:\n${testCode}`
|
|
230
|
+
: "Add a regression test that fails before your fix and passes after it, in this repo's test style.",
|
|
231
|
+
"Fix the root cause, not the symptom. If the report is wrong (it already works), show that with a passing test and say so in huddle_done.",
|
|
232
|
+
]
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join("\n");
|
|
235
|
+
const task = db.addTask({
|
|
236
|
+
title: capWords(`Fix: ${title}`, 120),
|
|
237
|
+
spec,
|
|
238
|
+
prefers: ["quickfix", ...(assignee ? [`@${assignee}`] : [])],
|
|
239
|
+
files: [...files, ...(testCode && !testFile.startsWith("(") ? [testFile] : [])],
|
|
240
|
+
});
|
|
241
|
+
filed.push({ task: task.id, title, files, round, by: from });
|
|
242
|
+
roundBugs++;
|
|
243
|
+
result.bugs++;
|
|
244
|
+
db.remember({ kind: "gotcha", body: `QA (${from}) found: ${title} — repro: ${cap(repro, 300)}`, why: "filed as a fix task", author: from });
|
|
245
|
+
db.publish("qa.bug", {
|
|
246
|
+
agent_id: from,
|
|
247
|
+
task_id: task.id,
|
|
248
|
+
payload: { round, by: from, title, repro: cap(repro, 500), files, assigned_to: assignee, test: !!testCode, reopened: !!reopened },
|
|
249
|
+
});
|
|
250
|
+
onEvent({ type: "bug", agent: from, round, title, task: task.id, assignedTo: assignee, files, test: !!testCode });
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
task_id: task.id,
|
|
254
|
+
assigned_to: assignee ?? "any free agent",
|
|
255
|
+
note: `Filed as fix task ${task.id}${assignee ? `, routed to ${assignee} (wrote ${files.join(", ")})` : ""}. Keep checking; huddle_done when finished.`,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
/** One QA agent checks main in a throwaway worktree. */
|
|
259
|
+
const check = (w, author, prompt) => new Promise((resolve) => {
|
|
260
|
+
const task = db.addTask({ title: `QA round ${round}: check the build`, spec: prompt, prefers: ["qa"] });
|
|
261
|
+
qaTasks.add(task.id);
|
|
262
|
+
db.claimTask(task.id, w.id); // claimed at once, so the fix board never picks it up
|
|
263
|
+
let wt;
|
|
264
|
+
let h;
|
|
265
|
+
try {
|
|
266
|
+
wt = createWorktree(repoRoot, w.id, task.id); // fresh off the integrated main
|
|
267
|
+
h = launchAgent({
|
|
268
|
+
repoRoot,
|
|
269
|
+
adapter: w.adapter,
|
|
270
|
+
agentId: w.id,
|
|
271
|
+
role: "worker", // it runs the suite and the app, not just reads
|
|
272
|
+
taskId: task.id,
|
|
273
|
+
taskSpec: `${task.title}\n\n${prompt}`,
|
|
274
|
+
roomUrl: room.url,
|
|
275
|
+
roomSecret: room.secret,
|
|
276
|
+
approvals: o.approvals,
|
|
277
|
+
worktree: wt,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
db.setTaskStatus(task.id, "failed");
|
|
282
|
+
db.setAgentStatus(w.id, "idle");
|
|
283
|
+
onEvent({ type: "checked", agent: w.id, round, ok: false, bugs: 0, ms: 0, tokens: 0, reason: `couldn't start: ${String(e?.message ?? e).slice(0, 200)}` });
|
|
284
|
+
return resolve();
|
|
285
|
+
}
|
|
286
|
+
worktrees.set(w.id, wt.path);
|
|
287
|
+
const before = roundBugsBy(w.id);
|
|
288
|
+
db.setTaskStatus(task.id, "in_progress");
|
|
289
|
+
db.setAgentStatus(w.id, "working", wt.path);
|
|
290
|
+
onEvent({ type: "start", agent: w.id, round, task: task.id, log: h.logPath, author });
|
|
291
|
+
let over = false;
|
|
292
|
+
const kill = () => stopChild(h.child);
|
|
293
|
+
const timer = setTimeout(kill, o.timeoutMs ?? QA_TIMEOUT_MS);
|
|
294
|
+
signal?.addEventListener("abort", kill);
|
|
295
|
+
const end = (code) => {
|
|
296
|
+
if (over)
|
|
297
|
+
return;
|
|
298
|
+
over = true;
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
signal?.removeEventListener("abort", kill);
|
|
301
|
+
const ms = Date.now() - h.startedAt;
|
|
302
|
+
db.releaseClaims(w.id);
|
|
303
|
+
db.setAgentStatus(w.id, "idle");
|
|
304
|
+
// "merged" = the check finished (a QA task lands nothing); "failed" = it didn't.
|
|
305
|
+
db.setTaskStatus(task.id, code === 0 ? "merged" : "failed");
|
|
306
|
+
removeWorktree(repoRoot, wt.path);
|
|
307
|
+
git(repoRoot, ["branch", "-D", wt.branch]);
|
|
308
|
+
worktrees.delete(w.id);
|
|
309
|
+
const bugs = roundBugsBy(w.id) - before;
|
|
310
|
+
const reason = code === 0 ? undefined : signal?.aborted ? "stopped by you" : `exited with code ${code}`;
|
|
311
|
+
if (code === 0) {
|
|
312
|
+
finished++;
|
|
313
|
+
slowest = Math.max(slowest, ms);
|
|
314
|
+
}
|
|
315
|
+
db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms } });
|
|
316
|
+
onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason, task: task.id });
|
|
317
|
+
resolve();
|
|
318
|
+
};
|
|
319
|
+
// Its last stdout lines (token usage) can still be in the pipe at 'exit'.
|
|
320
|
+
onFinished(h.child, end);
|
|
321
|
+
});
|
|
322
|
+
const roundBugsBy = (id) => filed.filter((b) => b.round === round && b.by === id).length;
|
|
323
|
+
const prev = room.bugs;
|
|
324
|
+
try {
|
|
325
|
+
for (round = 1; round <= rounds && !signal?.aborted; round++) {
|
|
326
|
+
roundBugs = 0;
|
|
327
|
+
finished = 0;
|
|
328
|
+
slowest = 0;
|
|
329
|
+
result.rounds = round;
|
|
330
|
+
const who = pick();
|
|
331
|
+
secondOpinion = new Set();
|
|
332
|
+
who.forEach(({ w }) => used.set(w.id, (used.get(w.id) ?? 0) + 1));
|
|
333
|
+
// authors: checkers who also wrote some of it (everyone did), so the story doesn't claim fresh eyes.
|
|
334
|
+
db.publish("qa.round", { payload: { round, agents: who.map((x) => x.w.id), authors: who.filter((x) => x.author).map((x) => x.w.id) } });
|
|
335
|
+
onEvent({ type: "round", round, agents: who.map((x) => x.w.id) });
|
|
336
|
+
const reviewers = reviewersOf(db);
|
|
337
|
+
const tasks = db
|
|
338
|
+
.listTasks()
|
|
339
|
+
.filter((t) => t.status === "merged" && !qaTasks.has(t.id))
|
|
340
|
+
.map((t) => {
|
|
341
|
+
const by = reviewers.get(t.id);
|
|
342
|
+
return `- ${t.title} · ${t.claimed_by ?? "?"} · ${by ? `reviewed by ${by}` : "NOT reviewed (merged on tests alone)"}`;
|
|
343
|
+
})
|
|
344
|
+
.join("\n");
|
|
345
|
+
const stat = git(repoRoot, ["diff", "--stat", `${o.startHead}..HEAD`]);
|
|
346
|
+
const diff = cap(git(repoRoot, ["diff", "--no-color", `${o.startHead}..HEAD`]), 20_000);
|
|
347
|
+
room.bugs = onBug;
|
|
348
|
+
await Promise.all(who.map(({ w, author }) => check(w, author, qaPrompt({ goal: o.goal, round, testCmd: o.testCmd(repoRoot), tasks, stat, diff, earlier: filed.filter((b) => b.round < round), author, maxBugs, checklist }))));
|
|
349
|
+
room.bugs = prev;
|
|
350
|
+
if (signal?.aborted)
|
|
351
|
+
break;
|
|
352
|
+
// Found live: a QA agent that hung until it was killed filed no bugs, and
|
|
353
|
+
// the round was reported "clean". No finished check = not checked; the
|
|
354
|
+
// next round (another agent, by rotation) checks again.
|
|
355
|
+
if (!roundBugs && !finished) {
|
|
356
|
+
const reason = "no QA check finished";
|
|
357
|
+
db.publish("qa.unchecked", { payload: { round, reason } });
|
|
358
|
+
onEvent({ type: "unchecked", round, reason });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!roundBugs) {
|
|
362
|
+
// A first clean round, quick, with nothing ever filed: one agent's word isn't enough.
|
|
363
|
+
// Trusted: after fixes (it re-checked them), a second clean, the last round, or a long check.
|
|
364
|
+
const trusted = result.bugs > 0 || cleanRounds > 0 || round === rounds || slowest >= minCleanMs;
|
|
365
|
+
cleanRounds++;
|
|
366
|
+
if (!trusted) {
|
|
367
|
+
secondOpinion = new Set(who.map(({ w }) => w.id));
|
|
368
|
+
const reason = `clean in ${Math.round(slowest / 1000)}s on the first look — another agent checks again`;
|
|
369
|
+
db.publish("qa.suspect", { payload: { round, ms: slowest, reason } });
|
|
370
|
+
onEvent({ type: "suspect", round, ms: slowest, reason });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
db.publish("qa.clean", { payload: { round } });
|
|
374
|
+
onEvent({ type: "clean", round });
|
|
375
|
+
result.clean = true;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
const fx = await o.runFixes();
|
|
379
|
+
result.fixed += fx.merged;
|
|
380
|
+
result.failed += fx.failed;
|
|
381
|
+
db.publish("qa.fixes", { payload: { round, merged: fx.merged, failed: fx.failed } });
|
|
382
|
+
onEvent({ type: "fixes", round, merged: fx.merged, failed: fx.failed });
|
|
383
|
+
if (fx.stopped)
|
|
384
|
+
break;
|
|
385
|
+
if (round === rounds) {
|
|
386
|
+
const open = filed.filter((b) => db.getTask(b.task)?.status !== "merged").length;
|
|
387
|
+
db.publish("qa.capped", { payload: { round, open } });
|
|
388
|
+
onEvent({ type: "capped", round, open });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
room.bugs = prev;
|
|
394
|
+
}
|
|
395
|
+
result.stopped = !!signal?.aborted;
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
//# sourceMappingURL=qa.js.map
|