@juzser/blacksmith 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/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { foldAgents } from './agents-registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Did the wave that was admitted N wide actually run N wide?
|
|
4
|
+
*
|
|
5
|
+
* `wave next` computes the widest wave a plan admits and `wave check` admits
|
|
6
|
+
* one. Both are statements about what MAY happen next: the first proposes, the
|
|
7
|
+
* second permits, and neither has any way of knowing what the dispatcher did
|
|
8
|
+
* afterwards. So a factory whose whole premise is "many subagents running the
|
|
9
|
+
* plan's tasks in parallel" could serialise every wave it ever admitted and
|
|
10
|
+
* say nothing about it — the admission would still be in the log, four ids
|
|
11
|
+
* wide, indistinguishable from a wave that genuinely ran four at once.
|
|
12
|
+
*
|
|
13
|
+
* That is the declarations-vs-state split this repo keeps drawing (AGENTS.md):
|
|
14
|
+
* `wave-admitted` is a declaration, and the only state that can answer it is
|
|
15
|
+
* when each agent was actually live. Both halves were already in the log —
|
|
16
|
+
* `dispatch_decision` opens an agent and its terminal event closes one, and
|
|
17
|
+
* agents-registry.ts already folds exactly that pair into intervals. So this
|
|
18
|
+
* is a fold over facts that were always there, not a new thing to record.
|
|
19
|
+
*
|
|
20
|
+
* It reuses `foldAgents` rather than reading `dispatch_decision` directly for
|
|
21
|
+
* the reason that fold documents (D-161): a dispatch superseded by a
|
|
22
|
+
* redispatch, or abandoned at `epic-closed`, has an end even though no
|
|
23
|
+
* terminal event names it, and reading the raw dispatches would leave those
|
|
24
|
+
* intervals open forever and score every wave after one as parallel.
|
|
25
|
+
*/
|
|
26
|
+
const ADMITTED_EVENT_TYPE = 'wave-admitted';
|
|
27
|
+
/**
|
|
28
|
+
* Said when a wave was admitted and no agent was ever dispatched for it. The
|
|
29
|
+
* two readings are genuinely different problems and the operator has to pick
|
|
30
|
+
* between them by hand, so the hint names both rather than choosing.
|
|
31
|
+
*/
|
|
32
|
+
export const UNOBSERVED_HINT = 'A wave admitted with no dispatch_decision under any of its tasks ran nowhere this log can ' +
|
|
33
|
+
'see. Either the dispatcher never started it, or the agents ran outside the lineage that was ' +
|
|
34
|
+
'read — narrow with --epic, or check that the run wrote its dispatches to this state dir.';
|
|
35
|
+
function readAdmission(event) {
|
|
36
|
+
const payload = event.record.payload;
|
|
37
|
+
const raw = payload.task_ids;
|
|
38
|
+
const taskIds = Array.isArray(raw) && raw.every((id) => typeof id === 'string' && id.length > 0)
|
|
39
|
+
? raw
|
|
40
|
+
: null;
|
|
41
|
+
if (!taskIds || taskIds.length === 0) {
|
|
42
|
+
// Refused rather than read as a zero-wide wave: a malformed admission that
|
|
43
|
+
// audited clean is exactly the silence this command exists to end.
|
|
44
|
+
throw new Error(`wave-concurrency.missing-task-ids: wave-admitted "${event.event_id}" names no tasks`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
eventId: event.event_id,
|
|
48
|
+
ts: event.record.ts,
|
|
49
|
+
// Never derived from the task ids. `emitWaveAdmitted` always writes this
|
|
50
|
+
// field, so an admission without one was hand-authored, and inventing the
|
|
51
|
+
// epic it probably meant would put a guess where the log has a gap.
|
|
52
|
+
epicId: typeof payload.epic_id === 'string' && payload.epic_id ? payload.epic_id : null,
|
|
53
|
+
taskIds,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Milliseconds, with an unclosed agent reaching forward without bound. */
|
|
57
|
+
function endMs(endedAt) {
|
|
58
|
+
return endedAt === null ? Number.POSITIVE_INFINITY : Date.parse(endedAt);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The most intervals overlapping at one instant. Ends are processed before
|
|
62
|
+
* starts at the same timestamp, so a task that finishes exactly as the next
|
|
63
|
+
* one begins counts as a handoff and not as concurrency — without that, a
|
|
64
|
+
* dispatcher running strictly one task at a time would score `parallel` on
|
|
65
|
+
* nothing but the clock's granularity.
|
|
66
|
+
*
|
|
67
|
+
* Times are doubled first so that a run whose dispatch and terminal share a
|
|
68
|
+
* millisecond still gets a width of one tick instead of none. On the raw axis
|
|
69
|
+
* such a run is an empty interval, overlapping nothing — not even itself —
|
|
70
|
+
* and two admitted tasks would come back as a peak of zero, which is a claim
|
|
71
|
+
* about the clock dressed up as a claim about the factory.
|
|
72
|
+
*/
|
|
73
|
+
function peakOverlap(runs) {
|
|
74
|
+
const points = runs.flatMap((run) => {
|
|
75
|
+
const start = Date.parse(run.startedAt) * 2;
|
|
76
|
+
return [
|
|
77
|
+
{ t: start, d: 1 },
|
|
78
|
+
{ t: Math.max(endMs(run.endedAt) * 2, start + 1), d: -1 },
|
|
79
|
+
];
|
|
80
|
+
});
|
|
81
|
+
// Subtraction would give NaN for two unclosed agents (Infinity - Infinity),
|
|
82
|
+
// and a NaN comparator silently leaves the array unsorted.
|
|
83
|
+
points.sort((a, b) => (a.t === b.t ? a.d - b.d : a.t < b.t ? -1 : 1));
|
|
84
|
+
let live = 0;
|
|
85
|
+
let peak = 0;
|
|
86
|
+
for (const point of points) {
|
|
87
|
+
live += point.d;
|
|
88
|
+
if (live > peak)
|
|
89
|
+
peak = live;
|
|
90
|
+
}
|
|
91
|
+
return peak;
|
|
92
|
+
}
|
|
93
|
+
function verdictFor(declared, observed, peak) {
|
|
94
|
+
if (declared <= 1)
|
|
95
|
+
return 'single';
|
|
96
|
+
if (observed === 0)
|
|
97
|
+
return 'unobserved';
|
|
98
|
+
if (peak >= declared)
|
|
99
|
+
return 'parallel';
|
|
100
|
+
if (peak <= 1)
|
|
101
|
+
return 'serialized';
|
|
102
|
+
return 'partial';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fold one task's agents into the interval it was worked on. Every role counts,
|
|
106
|
+
* not just the coder's: a task whose reviewer is still running is still work
|
|
107
|
+
* in progress, and a wave holding it has not narrowed yet.
|
|
108
|
+
*/
|
|
109
|
+
function runFor(taskId, agents) {
|
|
110
|
+
if (agents.length === 0)
|
|
111
|
+
return null;
|
|
112
|
+
const ordered = [...agents].sort((a, b) => a.dispatchedAt.localeCompare(b.dispatchedAt));
|
|
113
|
+
const startedAt = ordered[0]?.dispatchedAt;
|
|
114
|
+
let endedAt = null;
|
|
115
|
+
for (const agent of ordered) {
|
|
116
|
+
// One unclosed agent leaves the whole task open — the task is only done
|
|
117
|
+
// being worked on when every role on it is.
|
|
118
|
+
if (agent.terminalAt === null)
|
|
119
|
+
return { taskId, startedAt, endedAt: null, roles: rolesOf(ordered) };
|
|
120
|
+
if (endedAt === null || agent.terminalAt > endedAt)
|
|
121
|
+
endedAt = agent.terminalAt;
|
|
122
|
+
}
|
|
123
|
+
return { taskId, startedAt, endedAt, roles: rolesOf(ordered) };
|
|
124
|
+
}
|
|
125
|
+
function rolesOf(ordered) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const roles = [];
|
|
128
|
+
for (const agent of ordered) {
|
|
129
|
+
if (seen.has(agent.agentRole))
|
|
130
|
+
continue;
|
|
131
|
+
seen.add(agent.agentRole);
|
|
132
|
+
roles.push(agent.agentRole);
|
|
133
|
+
}
|
|
134
|
+
return roles;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every wave in the log, scored against what the log says it did.
|
|
138
|
+
*
|
|
139
|
+
* A task can be admitted more than once — a follow-up, a redispatch after a
|
|
140
|
+
* bounce — so each wave owns its tasks' agents only until the admission that
|
|
141
|
+
* re-opens them. Without that window, a later wave that genuinely ran wide
|
|
142
|
+
* would retro-fit its parallelism onto the earlier serial one that admitted
|
|
143
|
+
* the same task, and the command would report the opposite of the truth.
|
|
144
|
+
*/
|
|
145
|
+
export function auditWaveConcurrency(events, options = {}) {
|
|
146
|
+
const admissions = events
|
|
147
|
+
.filter((event) => event.record.event_type === ADMITTED_EVENT_TYPE)
|
|
148
|
+
.map(readAdmission)
|
|
149
|
+
.sort((a, b) => (a.ts === b.ts ? a.eventId.localeCompare(b.eventId) : a.ts < b.ts ? -1 : 1));
|
|
150
|
+
// Built from every admission, before the epic filter: a window closed by an
|
|
151
|
+
// admission the caller filtered out is still closed, and asking about one
|
|
152
|
+
// epic must not widen another epic's windows.
|
|
153
|
+
const admittedAtByTask = new Map();
|
|
154
|
+
for (const admission of admissions) {
|
|
155
|
+
for (const taskId of admission.taskIds) {
|
|
156
|
+
const list = admittedAtByTask.get(taskId);
|
|
157
|
+
if (list)
|
|
158
|
+
list.push(admission.ts);
|
|
159
|
+
else
|
|
160
|
+
admittedAtByTask.set(taskId, [admission.ts]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const agentsByTask = new Map();
|
|
164
|
+
for (const agent of foldAgents(events)) {
|
|
165
|
+
if (!agent.taskId)
|
|
166
|
+
continue;
|
|
167
|
+
const list = agentsByTask.get(agent.taskId);
|
|
168
|
+
if (list)
|
|
169
|
+
list.push(agent);
|
|
170
|
+
else
|
|
171
|
+
agentsByTask.set(agent.taskId, [agent]);
|
|
172
|
+
}
|
|
173
|
+
const waves = [];
|
|
174
|
+
for (const admission of admissions) {
|
|
175
|
+
if (options.epicId !== undefined && admission.epicId !== options.epicId)
|
|
176
|
+
continue;
|
|
177
|
+
const observed = [];
|
|
178
|
+
const unobserved = [];
|
|
179
|
+
for (const taskId of admission.taskIds) {
|
|
180
|
+
const closesAt = (admittedAtByTask.get(taskId) ?? []).find((ts) => ts > admission.ts);
|
|
181
|
+
const run = runFor(taskId, (agentsByTask.get(taskId) ?? []).filter((agent) =>
|
|
182
|
+
// A run already under way when the wave was cut is not this wave's
|
|
183
|
+
// to claim; a run after the next admission belongs to that one.
|
|
184
|
+
agent.dispatchedAt >= admission.ts &&
|
|
185
|
+
(closesAt === undefined || agent.dispatchedAt < closesAt)));
|
|
186
|
+
if (run)
|
|
187
|
+
observed.push(run);
|
|
188
|
+
else
|
|
189
|
+
unobserved.push(taskId);
|
|
190
|
+
}
|
|
191
|
+
observed.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
192
|
+
const peak = peakOverlap(observed);
|
|
193
|
+
waves.push({
|
|
194
|
+
eventId: admission.eventId,
|
|
195
|
+
admittedAt: admission.ts,
|
|
196
|
+
epicId: admission.epicId,
|
|
197
|
+
declared: admission.taskIds,
|
|
198
|
+
observed,
|
|
199
|
+
unobserved,
|
|
200
|
+
peak,
|
|
201
|
+
verdict: verdictFor(admission.taskIds.length, observed.length, peak),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return waves;
|
|
205
|
+
}
|
|
206
|
+
/** The handle an operator has on a wave: its epic, or its own id when it named none. */
|
|
207
|
+
function labelOf(wave) {
|
|
208
|
+
return wave.epicId ?? wave.eventId;
|
|
209
|
+
}
|
|
210
|
+
function labelsWith(waves, verdict) {
|
|
211
|
+
const labels = [];
|
|
212
|
+
for (const wave of waves) {
|
|
213
|
+
if (wave.verdict !== verdict)
|
|
214
|
+
continue;
|
|
215
|
+
const label = labelOf(wave);
|
|
216
|
+
// Once per epic, not once per wave: an epic that serialised every wave it
|
|
217
|
+
// ever cut has one problem, and listing it eight times reads as eight.
|
|
218
|
+
if (!labels.includes(label))
|
|
219
|
+
labels.push(label);
|
|
220
|
+
}
|
|
221
|
+
return labels;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Score the audit.
|
|
225
|
+
*
|
|
226
|
+
* Exit 1 is `serialized` alone. A `partial` wave — three admitted, two in
|
|
227
|
+
* flight — is the factory working and is reported rather than failed: an exit
|
|
228
|
+
* code that cried about it would be routed to /dev/null inside a week, and
|
|
229
|
+
* then the serialized one would go with it.
|
|
230
|
+
*
|
|
231
|
+
* Exit 2 is the same shape `judge escalations` uses: nothing was judged. A
|
|
232
|
+
* wave the log holds no dispatch for is not a passing wave, and returning 0
|
|
233
|
+
* for it would make an empty state dir look like a healthy factory.
|
|
234
|
+
*/
|
|
235
|
+
export function summariseWaveConcurrency(waves) {
|
|
236
|
+
const serialized = labelsWith(waves, 'serialized');
|
|
237
|
+
const unobserved = labelsWith(waves, 'unobserved');
|
|
238
|
+
return {
|
|
239
|
+
waves: [...waves],
|
|
240
|
+
serialized,
|
|
241
|
+
partial: labelsWith(waves, 'partial'),
|
|
242
|
+
unobserved,
|
|
243
|
+
widest: {
|
|
244
|
+
declared: waves.reduce((max, wave) => Math.max(max, wave.declared.length), 0),
|
|
245
|
+
observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
|
|
246
|
+
},
|
|
247
|
+
hint: unobserved.length > 0 ? UNOBSERVED_HINT : '',
|
|
248
|
+
exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 ? 2 : 0,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widest wave the graph allows, computed instead of guessed.
|
|
3
|
+
*
|
|
4
|
+
* `wave check` answers a closed question — may *these* task ids run together
|
|
5
|
+
* — and answers it well. But it only ever sees a set someone already picked,
|
|
6
|
+
* and the safest set to pick is always a set of one: a single task is
|
|
7
|
+
* pairwise-disjoint with nothing, shares no hotspot with nothing, and crosses
|
|
8
|
+
* no import edge with nothing, so it passes every gate in the file. The gate
|
|
9
|
+
* cannot tell a deliberate wave of one from an orchestrator that never
|
|
10
|
+
* thought to ask for more, and both come back `valid: true`. A factory whose
|
|
11
|
+
* whole claim is parallel execution therefore had no command that computes
|
|
12
|
+
* parallelism; it had a command that declines to forbid it.
|
|
13
|
+
*
|
|
14
|
+
* `waveLayers` in graph.ts already bands a plan by longest-path depth, and
|
|
15
|
+
* its docblock says the band means "these can run in parallel right now" —
|
|
16
|
+
* but nothing outside the Flow page's layout ever imported it, so the one
|
|
17
|
+
* function that knew the answer only ever drew it.
|
|
18
|
+
*
|
|
19
|
+
* This module closes that gap from the other end: given a plan, the live
|
|
20
|
+
* status of its tasks and the import-graph crossings between them, it
|
|
21
|
+
* returns the largest set that `wave check` would admit, plus a named reason
|
|
22
|
+
* for every task it left out. It is pure and it writes nothing. Admission
|
|
23
|
+
* stays where it was — `wave check` remains the single command that emits
|
|
24
|
+
* `wave-admitted`, so a proposal here still has to survive the gate that
|
|
25
|
+
* enforces it, and a bug in this file costs a rejected proposal rather than
|
|
26
|
+
* two agents in one worktree.
|
|
27
|
+
*/
|
|
28
|
+
import { claimsOverlap, readClaimList, readEdgeList, touchesSerializeAlways, } from './claims.js';
|
|
29
|
+
import { topoSort } from './graph.js';
|
|
30
|
+
import { livePlanTasks } from './plan.js';
|
|
31
|
+
/**
|
|
32
|
+
* A task is *done* only at the two statuses epic.ts already calls terminal —
|
|
33
|
+
* `completed` and `waived`. Everything else non-terminal still holds a
|
|
34
|
+
* worktree, so its claims are still spoken for even when nobody is typing in
|
|
35
|
+
* it: `blocked`, `failed` and `escalated` are waiting on a person, not on
|
|
36
|
+
* nothing, and their uncommitted work is exactly what a second agent in the
|
|
37
|
+
* same paths would destroy.
|
|
38
|
+
*/
|
|
39
|
+
const TERMINAL_TASK_STATUSES = new Set(['completed', 'waived']);
|
|
40
|
+
/** The two statuses that mean "nothing has been dispatched for this yet". */
|
|
41
|
+
const CANDIDATE_TASK_STATUSES = new Set(['todo', 'ready']);
|
|
42
|
+
function nameList(ids) {
|
|
43
|
+
return ids.join(', ');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Order the candidates so that when two of them are coupled, the one that
|
|
47
|
+
* must land first is the one offered admission. Plan edges and import
|
|
48
|
+
* crossings are the same shape of constraint here — "this one after that
|
|
49
|
+
* one" — so both feed one sort, and a coupled pair resolves the way the
|
|
50
|
+
* playbook already resolves it by hand: run the producer first.
|
|
51
|
+
*
|
|
52
|
+
* A cycle among the crossings is not this function's to diagnose (plan.ts
|
|
53
|
+
* rejects cyclic plan edges; import cycles are legal TypeScript), so it falls
|
|
54
|
+
* back to lexicographic order — an arbitrary but stable pick, which still
|
|
55
|
+
* admits one of the pair and defers the other with a reason.
|
|
56
|
+
*/
|
|
57
|
+
function admissionOrder(candidates, edges, crossings) {
|
|
58
|
+
const byId = new Map(candidates.map((t) => [t.task_id, t]));
|
|
59
|
+
const ids = [...byId.keys()].sort();
|
|
60
|
+
const ordering = [
|
|
61
|
+
...edges,
|
|
62
|
+
...crossings.map((c) => ({ task: c.consumer, dependsOn: c.producer })),
|
|
63
|
+
];
|
|
64
|
+
const sorted = topoSort(ids, ordering);
|
|
65
|
+
const order = sorted.ok ? sorted.order : ids;
|
|
66
|
+
return order.flatMap((id) => {
|
|
67
|
+
const task = byId.get(id);
|
|
68
|
+
return task ? [task] : []; // provably total: topoSort returns only ids it was given
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every live task in the plan, with its claims read as a list of globs.
|
|
73
|
+
*
|
|
74
|
+
* Exists because a caller has to hand `waveImpact` a readable claim set
|
|
75
|
+
* before `computeNextWave` runs, and there must not be a second, laxer way to
|
|
76
|
+
* obtain one. `readClaimList` is the door `wave check` refuses through; a CLI
|
|
77
|
+
* that reached past it — `(t.claims ?? []) as string[]` is the obvious
|
|
78
|
+
* shortcut — would hand the symbol graph a claims string to iterate by
|
|
79
|
+
* character, and a task claiming nothing crosses nobody. The wave would then
|
|
80
|
+
* be admitted for the one reason it must not be: the check could not read it.
|
|
81
|
+
*
|
|
82
|
+
* Terminal tasks are kept. The graph is built over the whole live plan
|
|
83
|
+
* because a crossing with an in-flight task is exactly the crossing that has
|
|
84
|
+
* to defer a candidate, and a caller cannot know which tasks are in flight
|
|
85
|
+
* until `computeNextWave` has partitioned them by status.
|
|
86
|
+
*/
|
|
87
|
+
export function liveWaveTasks(plan) {
|
|
88
|
+
return livePlanTasks(plan).map((record) => readClaimList({
|
|
89
|
+
task_id: record.task_id,
|
|
90
|
+
claims: record.claims,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
export function computeNextWave(input) {
|
|
94
|
+
const { plan, policy } = input;
|
|
95
|
+
const crossings = input.crossings ?? [];
|
|
96
|
+
const edges = readEdgeList(plan.edges);
|
|
97
|
+
const done = [];
|
|
98
|
+
const occupied = [];
|
|
99
|
+
const occupiedTasks = [];
|
|
100
|
+
const candidates = [];
|
|
101
|
+
for (const record of livePlanTasks(plan)) {
|
|
102
|
+
const id = record.task_id;
|
|
103
|
+
const status = input.statusById?.get(id) ?? String(record.task_status);
|
|
104
|
+
if (TERMINAL_TASK_STATUSES.has(status)) {
|
|
105
|
+
done.push(id);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Past this door claims are a real string[], for a candidate and for a
|
|
109
|
+
// task merely holding ground — the wave is unanswerable either way.
|
|
110
|
+
const task = readClaimList({ task_id: id, claims: record.claims });
|
|
111
|
+
if (CANDIDATE_TASK_STATUSES.has(status)) {
|
|
112
|
+
candidates.push(task);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
occupied.push(id);
|
|
116
|
+
occupiedTasks.push(task);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
done.sort();
|
|
120
|
+
occupied.sort();
|
|
121
|
+
const doneSet = new Set(done);
|
|
122
|
+
const dependsByTask = new Map();
|
|
123
|
+
for (const edge of edges) {
|
|
124
|
+
const list = dependsByTask.get(edge.task) ?? [];
|
|
125
|
+
list.push(edge.dependsOn);
|
|
126
|
+
dependsByTask.set(edge.task, list);
|
|
127
|
+
}
|
|
128
|
+
const coupledWith = new Map();
|
|
129
|
+
const link = (a, b, role) => {
|
|
130
|
+
const map = coupledWith.get(a) ?? new Map();
|
|
131
|
+
map.set(b, role);
|
|
132
|
+
coupledWith.set(a, map);
|
|
133
|
+
};
|
|
134
|
+
for (const crossing of crossings) {
|
|
135
|
+
link(crossing.consumer, crossing.producer, 'consumes');
|
|
136
|
+
link(crossing.producer, crossing.consumer, 'produces');
|
|
137
|
+
}
|
|
138
|
+
const admitted = [];
|
|
139
|
+
const deferred = [];
|
|
140
|
+
const defer = (taskId, reason, blockedBy, detail) => {
|
|
141
|
+
deferred.push({ taskId, reason, blockedBy: [...blockedBy].sort(), detail });
|
|
142
|
+
};
|
|
143
|
+
for (const task of admissionOrder(candidates, edges, crossings)) {
|
|
144
|
+
const id = task.task_id;
|
|
145
|
+
const pending = [...new Set(dependsByTask.get(id) ?? [])].filter((d) => !doneSet.has(d)).sort();
|
|
146
|
+
if (pending.length > 0) {
|
|
147
|
+
defer(id, 'dependency-pending', pending, `Depends on ${nameList(pending)}, which ${pending.length === 1 ? 'is' : 'are'} not terminal.`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// Occupied tasks hold their claims for the same reason admitted ones do:
|
|
151
|
+
// a worktree exists and its edits are not merged.
|
|
152
|
+
const holders = [...admitted, ...occupiedTasks];
|
|
153
|
+
const overlapping = [];
|
|
154
|
+
let overlapDetail = '';
|
|
155
|
+
for (const holder of holders) {
|
|
156
|
+
const overlap = claimsOverlap(task, holder);
|
|
157
|
+
if (!overlap.overlaps)
|
|
158
|
+
continue;
|
|
159
|
+
overlapping.push(holder.task_id);
|
|
160
|
+
const pair = overlap.offendingGlobs[0];
|
|
161
|
+
if (overlapDetail === '' && pair !== undefined) {
|
|
162
|
+
overlapDetail = `Claims overlap ${holder.task_id} (${pair.globA} vs ${pair.globB}).`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (overlapping.length > 0) {
|
|
166
|
+
defer(id, 'claim-overlap', overlapping, overlapDetail);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const hotspots = touchesSerializeAlways(task, policy.serializeAlwaysGlobs);
|
|
170
|
+
const sharing = [];
|
|
171
|
+
let hotspotDetail = '';
|
|
172
|
+
if (hotspots.length > 0) {
|
|
173
|
+
for (const holder of holders) {
|
|
174
|
+
const glob = touchesSerializeAlways(holder, hotspots)[0];
|
|
175
|
+
if (glob === undefined)
|
|
176
|
+
continue;
|
|
177
|
+
sharing.push(holder.task_id);
|
|
178
|
+
if (hotspotDetail === '') {
|
|
179
|
+
hotspotDetail =
|
|
180
|
+
`Shares the serialize-always glob ${glob} with ${holder.task_id}, ` +
|
|
181
|
+
'which worktree.yml never schedules concurrently.';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (sharing.length > 0) {
|
|
186
|
+
defer(id, 'serialize-hotspot', sharing, hotspotDetail);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const couplings = coupledWith.get(id);
|
|
190
|
+
const coupled = couplings ? holders.filter((h) => couplings.has(h.task_id)) : [];
|
|
191
|
+
const blocker = coupled[0];
|
|
192
|
+
if (couplings !== undefined && blocker !== undefined) {
|
|
193
|
+
const detail = couplings.get(blocker.task_id) === 'consumes'
|
|
194
|
+
? `Imports symbols from ${blocker.task_id}, which has not merged: the producer runs first.`
|
|
195
|
+
: `${blocker.task_id} imports symbols from it and is already scheduled, so this one follows.`;
|
|
196
|
+
defer(id, 'symbol-coupled', coupled.map((h) => h.task_id), detail);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
admitted.push(task);
|
|
200
|
+
}
|
|
201
|
+
deferred.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
202
|
+
return {
|
|
203
|
+
epicId: plan.epic_id,
|
|
204
|
+
wave: admitted.map((t) => t.task_id),
|
|
205
|
+
deferred,
|
|
206
|
+
done,
|
|
207
|
+
occupied,
|
|
208
|
+
remaining: deferred.length + occupied.length,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide this plan can *ever* run, answered before an agent is dispatched.
|
|
3
|
+
*
|
|
4
|
+
* The factory now brackets parallelism from two sides and neither one reaches
|
|
5
|
+
* the plan. `wave check` certifies that a set someone already picked *could*
|
|
6
|
+
* run together; `wave audit` reads the log afterwards and reports whether it
|
|
7
|
+
* *did*. Both take the plan as given. But a plan whose tasks all claim
|
|
8
|
+
* overlapping globs has a ceiling of one no matter how many agents are
|
|
9
|
+
* available, and a planner that sliced it that way gets the same silent
|
|
10
|
+
* `valid: true` from every gate downstream — each wave of one is admitted,
|
|
11
|
+
* each run of one is faithful to its admission, and the epic serializes with
|
|
12
|
+
* nothing anywhere reporting a problem. The cost is paid at plan time and
|
|
13
|
+
* every check that could have named it runs too late to matter.
|
|
14
|
+
*
|
|
15
|
+
* So this module replays the dispatcher against the plan. It calls the same
|
|
16
|
+
* pure `computeNextWave` the real wave loop calls, marks the returned wave
|
|
17
|
+
* terminal, and calls it again until nothing more can start. What comes back
|
|
18
|
+
* is the number of rounds the plan needs and the widest round it ever gets --
|
|
19
|
+
* computed by the containment code itself, so the ceiling reported here is
|
|
20
|
+
* the ceiling the dispatcher will actually hit, not a second opinion about
|
|
21
|
+
* it that can drift.
|
|
22
|
+
*
|
|
23
|
+
* The one distinction that makes the output actionable: a task deferred
|
|
24
|
+
* `dependency-pending` is waiting on work it genuinely needs, and that is the
|
|
25
|
+
* shape of the problem, not a defect — a chain of five real dependencies
|
|
26
|
+
* takes five rounds and no re-slicing changes it. A task deferred for any
|
|
27
|
+
* other reason had its dependencies satisfied, was ready to run that round,
|
|
28
|
+
* and was held back purely by how the planner drew the claims. Only the
|
|
29
|
+
* second kind is anybody's to fix, so only the second kind is reported as a
|
|
30
|
+
* constraint.
|
|
31
|
+
*
|
|
32
|
+
* What this deliberately does not do is claim a counterfactual. It never says
|
|
33
|
+
* "re-slice these claims and the plan runs in three rounds instead of five",
|
|
34
|
+
* because knowing that would mean inventing the claim geometry the planner
|
|
35
|
+
* would have written instead, and the dependency graph may simply bind next.
|
|
36
|
+
* It names which tasks were held back, by whom, and in which round. Deciding
|
|
37
|
+
* whether that is worth re-planning is the operator's, and the numbers are
|
|
38
|
+
* there to decide on.
|
|
39
|
+
*
|
|
40
|
+
* Pure, and it writes nothing. Like `waveNext`, admission stays where it was:
|
|
41
|
+
* a round simulated here has been admitted by nobody.
|
|
42
|
+
*/
|
|
43
|
+
import { computeNextWave, } from './waveNext.js';
|
|
44
|
+
/**
|
|
45
|
+
* Reported in this order, which is the order `computeNextWave` tests them in.
|
|
46
|
+
* `dependency-pending` is absent on purpose — it is the one reason that is
|
|
47
|
+
* never a finding.
|
|
48
|
+
*/
|
|
49
|
+
const AVOIDABLE_REASONS = [
|
|
50
|
+
'claim-overlap',
|
|
51
|
+
'serialize-hotspot',
|
|
52
|
+
'symbol-coupled',
|
|
53
|
+
];
|
|
54
|
+
function nameList(ids) {
|
|
55
|
+
return ids.join(', ');
|
|
56
|
+
}
|
|
57
|
+
function buildHint(stalled, occupied, constraints, depth) {
|
|
58
|
+
if (stalled.length > 0) {
|
|
59
|
+
const held = constraints.flatMap((c) => c.blockedBy).filter((id) => occupied.includes(id));
|
|
60
|
+
const because = held.length > 0
|
|
61
|
+
? ` ${nameList([...new Set(held)].sort())} still holds claims and is waiting on a person, not on the schedule.`
|
|
62
|
+
: '';
|
|
63
|
+
return (`The schedule stalls: ${nameList(stalled)} can never start, ` +
|
|
64
|
+
`so ${stalled.length === 1 ? 'that task' : 'those tasks'} will not run as this plan is written.${because}`);
|
|
65
|
+
}
|
|
66
|
+
if (constraints.length > 0) {
|
|
67
|
+
const reasons = constraints.map((c) => c.reason);
|
|
68
|
+
return (`This plan runs in ${depth} ${depth === 1 ? 'round' : 'rounds'}, and some of that ` +
|
|
69
|
+
`depth is claim geometry rather than dependencies (${nameList(reasons)}). ` +
|
|
70
|
+
'Re-slicing the claims may widen it; whether it does depends on the dependency graph, ' +
|
|
71
|
+
'which this command does not speculate about.');
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Replay the wave loop against a plan until nothing more can start.
|
|
77
|
+
*
|
|
78
|
+
* Termination is structural, not capped: every iteration either admits at
|
|
79
|
+
* least one task — which is then marked terminal, strictly shrinking the
|
|
80
|
+
* candidate set — or admits none, and the loop ends. So it runs at most
|
|
81
|
+
* once per task plus the final unproductive round that proves there is
|
|
82
|
+
* nothing left.
|
|
83
|
+
*/
|
|
84
|
+
export function scheduleWaves(input) {
|
|
85
|
+
// A copy, because a caller's status register is theirs and this simulation
|
|
86
|
+
// completes tasks that have not been dispatched.
|
|
87
|
+
const statusById = new Map(input.statusById ?? []);
|
|
88
|
+
const rounds = [];
|
|
89
|
+
const accumulators = new Map();
|
|
90
|
+
let epicId = input.plan.epic_id;
|
|
91
|
+
let stalled = [];
|
|
92
|
+
let occupied = [];
|
|
93
|
+
for (;;) {
|
|
94
|
+
const result = computeNextWave({ ...input, statusById });
|
|
95
|
+
epicId = result.epicId;
|
|
96
|
+
occupied = result.occupied;
|
|
97
|
+
const avoidable = result.deferred.filter((d) => d.reason !== 'dependency-pending');
|
|
98
|
+
for (const deferral of avoidable) {
|
|
99
|
+
const acc = accumulators.get(deferral.reason) ?? {
|
|
100
|
+
tasks: new Set(),
|
|
101
|
+
blockedBy: new Set(),
|
|
102
|
+
detail: deferral.detail,
|
|
103
|
+
};
|
|
104
|
+
acc.tasks.add(deferral.taskId);
|
|
105
|
+
for (const holder of deferral.blockedBy)
|
|
106
|
+
acc.blockedBy.add(holder);
|
|
107
|
+
accumulators.set(deferral.reason, acc);
|
|
108
|
+
}
|
|
109
|
+
if (result.wave.length === 0) {
|
|
110
|
+
// The round that starts nothing is the round that names the stall:
|
|
111
|
+
// anything still deferred here is a candidate no later round reaches.
|
|
112
|
+
stalled = result.deferred.map((d) => d.taskId).sort();
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
rounds.push({ round: rounds.length + 1, tasks: result.wave, avoidable });
|
|
116
|
+
for (const taskId of result.wave)
|
|
117
|
+
statusById.set(taskId, 'completed');
|
|
118
|
+
}
|
|
119
|
+
const constraints = AVOIDABLE_REASONS.flatMap((reason) => {
|
|
120
|
+
const acc = accumulators.get(reason);
|
|
121
|
+
if (acc === undefined)
|
|
122
|
+
return [];
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
reason,
|
|
126
|
+
tasks: [...acc.tasks].sort(),
|
|
127
|
+
blockedBy: [...acc.blockedBy].sort(),
|
|
128
|
+
detail: acc.detail,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
});
|
|
132
|
+
const depth = rounds.length;
|
|
133
|
+
const hint = buildHint(stalled, occupied, constraints, depth);
|
|
134
|
+
return {
|
|
135
|
+
epicId,
|
|
136
|
+
rounds,
|
|
137
|
+
depth,
|
|
138
|
+
widest: rounds.reduce((max, r) => Math.max(max, r.tasks.length), 0),
|
|
139
|
+
scheduled: rounds.reduce((total, r) => total + r.tasks.length, 0),
|
|
140
|
+
stalled,
|
|
141
|
+
occupied,
|
|
142
|
+
constraints,
|
|
143
|
+
hint,
|
|
144
|
+
exitCode: stalled.length > 0 ? 1 : constraints.length > 0 ? 2 : 0,
|
|
145
|
+
};
|
|
146
|
+
}
|