faberun 0.25.1 → 0.26.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/package.json +1 -1
- package/skills/faberun/references/contract.md +30 -26
- package/src/campaign/campaign-brief-graph.mjs +218 -0
- package/src/campaign/campaign-brief.mjs +15 -167
- package/src/campaign/metrics-command.mjs +3 -1
- package/src/cli/brand.mjs +1 -0
- package/src/cli/launch.mjs +14 -1
- package/src/cli/plan.mjs +75 -3
- package/src/cli/setup.mjs +22 -5
- package/src/cli.mjs +35 -1
- package/src/contract/definition-of-done.mjs +48 -8
- package/src/contract/human-step.mjs +54 -0
- package/src/contract/index.mjs +25 -38
- package/src/contract/judge-independence.mjs +170 -0
- package/src/contract/judges.mjs +27 -0
- package/src/contract/provider.mjs +103 -0
- package/src/contract/runtime.mjs +19 -2
- package/src/contract/snapshot.mjs +31 -1
- package/src/engine/assignment.mjs +41 -5
- package/src/engine/backoff.mjs +30 -9
- package/src/engine/dispatch.mjs +19 -2
- package/src/engine/failover.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -4
- package/src/engine/judge-list.mjs +206 -0
- package/src/engine/lifecycle.mjs +19 -16
- package/src/engine/live-preflight.mjs +45 -7
- package/src/engine/resume.mjs +46 -9
- package/src/engine/run-identity.mjs +9 -6
- package/src/engine/runtime-discovery.mjs +72 -11
- package/src/engine/scheduler.mjs +27 -6
- package/src/harnesses/catalogue.mjs +9 -2
- package/src/harnesses/index.mjs +2 -2
- package/src/harnesses/model-tiers.mjs +39 -0
- package/src/harnesses/protocol.mjs +4 -1
- package/src/host/config.mjs +7 -2
- package/src/plan/contest.mjs +78 -0
- package/src/plan/freeze.mjs +118 -7
- package/src/plan/human-step.mjs +38 -0
- package/src/plan/pipeline.mjs +224 -313
- package/src/plan/preflight.mjs +5 -3
- package/src/plan/proof-check.mjs +161 -0
- package/src/plan/proof-run.mjs +6 -2
- package/src/plan/proof-scope.mjs +175 -0
- package/src/plan/resolve.mjs +182 -0
- package/src/plan/reviewer.mjs +82 -0
- package/src/plan/rounds.mjs +446 -0
- package/src/plan/routing.mjs +11 -4
- package/src/plan/template.mjs +36 -14
- package/src/repo/signal-block.mjs +10 -3
- package/src/repo/source-identity.mjs +45 -5
- package/src/repo/worktree-prune.mjs +72 -0
- package/src/report/campaign-brief-estimate.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/metrics-report.mjs +34 -1
- package/src/report/render.mjs +31 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,9 +22,9 @@ typecheck`). Schema version is `3`.
|
|
|
22
22
|
"vendor": "deepseek", "sandbox": "danger-full-access",
|
|
23
23
|
"config": { "provider": "deepseek-official", "api_key.env_key": "DEEPSEEK_API_KEY" } },
|
|
24
24
|
"luna": { "harness": "codex", "model": "gpt-5.6-luna", "reasoning": "xhigh" },
|
|
25
|
-
"sol": { "harness": "codex", "model": "gpt-5.6-sol", "reasoning": "xhigh"
|
|
25
|
+
"sol": { "harness": "codex", "model": "gpt-5.6-sol", "reasoning": "xhigh" },
|
|
26
26
|
"opus": { "harness": "claude", "model": "opus", "permissionMode": "acceptEdits" },
|
|
27
|
-
"zcode-flash": { "harness": "zcode", "model": "glm-5.3-flash", "
|
|
27
|
+
"zcode-flash": { "harness": "zcode", "model": "glm-5.3-flash", "permissionMode": "edit" },
|
|
28
28
|
"agy-flash": { "harness": "agy", "model": "gemini-3.8-flash-low" }
|
|
29
29
|
},
|
|
30
30
|
"nodes": [
|
|
@@ -148,32 +148,36 @@ to its `readFiles` (every planning stage) delivers through `output`, `artifacts`
|
|
|
148
148
|
## Runtimes and routing
|
|
149
149
|
|
|
150
150
|
Resolve a worker as `nodes[].runtime`, then `runtimeDefaults.worker`; a judge
|
|
151
|
-
as `nodes[].gate.runtime`, then `runtimeDefaults.judge
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
as `nodes[].gate.runtime`, then `runtimeDefaults.judge`, then a contract's (or
|
|
152
|
+
absent one, the machine config's) ordered `judges` list: its first entry off
|
|
153
|
+
the worker's provider and its fallback chain's, unrefused and under a 90%
|
|
154
|
+
usage window, hopping the same way on a later refusal without repeats,
|
|
155
|
+
replacing any declared `fallback` edge outright, and blocking the node by name
|
|
156
|
+
when every entry is skipped (R18).
|
|
157
|
+
When `runtimes` and `runtimeDefaults` are both omitted, the factory composes
|
|
158
|
+
them from the discovery catalogue (`DISCOVERY_RUNTIME_DEFINITIONS`:
|
|
159
|
+
`dsh-deepseek`, `zcode-glm`, `agy-gemini` at tier 1, `codex-gpt` and
|
|
160
|
+
`claude-sonnet` at tier 2; available when the binary answers and every
|
|
161
|
+
`config["*.env_key"]` it names is set): the cheapest available runtime
|
|
162
|
+
executes, the strongest runtime of a *different vendor* judges, persisted in
|
|
163
|
+
`routing.assignments`; no admissible cross-vendor judge fails by name
|
|
164
|
+
(`runtime_assignment_judge_unavailable`).
|
|
159
165
|
|
|
160
166
|
`harness` names the adapter that runs the turn (`claude`, `codex`, `agy`,
|
|
161
|
-
`dsh`, `zcode`, `exec-jsonl`, `replay`) and `model` what it asks; the two
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
it; ids take letters, numbers, dot, underscore, dash only. Vendor is
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
attempt) and is instead refused at execution; see Failover below. Two models of one family (a GLM 5.3-flash worker judged by GLM 5.3) pair only by
|
|
176
|
-
declaring distinct `vendor` strings — a claim about review independence.
|
|
167
|
+
`dsh`, `zcode`, `exec-jsonl`, `replay`) and `model` what it asks; the two vary
|
|
168
|
+
independently — DeepSeek answers through `dsh`, GLM through `zcode`. Name a
|
|
169
|
+
runtime id `<harness>-<model>` so a recorded run says which harness produced
|
|
170
|
+
it; ids take letters, numbers, dot, underscore, dash only. Vendor is the
|
|
171
|
+
canonical provider (`canonicalProvider` in `src/contract/provider.mjs`)
|
|
172
|
+
derived from route, model family, or harness default (`claude`→anthropic,
|
|
173
|
+
`codex`→openai, `agy`→google, `zcode`→zhipu; `dsh`/`replay`/`exec-jsonl` have
|
|
174
|
+
none and must declare `vendor`), falling back to `resolveVendor` only when it
|
|
175
|
+
derives none; a declared `vendor` that contradicts it is refused. Validation
|
|
176
|
+
rejects a gate-enabled node whose worker and judge resolve to the same
|
|
177
|
+
vendor, and does the same for every runtime in the worker's declared fallback
|
|
178
|
+
chain (rejecting a cycle outright). The symmetric case for a judge fallback
|
|
179
|
+
depends on which worker runtime ran and is refused at execution instead; see
|
|
180
|
+
Failover below.
|
|
177
181
|
|
|
178
182
|
An optional `runtimes[<id>].pricing` object declares `inputPerMTok`,
|
|
179
183
|
`cachedInputPerMTok`, and `outputPerMTok` (each finite and >= 0, at
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Campaign Brief's work graph: dependency edges, per-node worker/judge
|
|
3
|
+
* runtimes, per-runtime concurrency caps and the schedule they allow. Split
|
|
4
|
+
* out of `campaign-brief.mjs` (R20), already at this repository's 800-line
|
|
5
|
+
* ceiling, so the same-provider review marking (R20) had a file with room to
|
|
6
|
+
* land in.
|
|
7
|
+
*
|
|
8
|
+
* The frozen contract this reads has already passed `validateContract`, which
|
|
9
|
+
* is the one place that enforces same-vendor mode's tier rule
|
|
10
|
+
* (`contract/judge-independence.mjs`); a node this module marks
|
|
11
|
+
* `sameProviderReview` could only exist here because that check already
|
|
12
|
+
* passed, so the mark is a plain vendor-and-opt-in comparison, not a second
|
|
13
|
+
* enforcement of the rule.
|
|
14
|
+
*/
|
|
15
|
+
import { asArray } from "./brief-text.mjs";
|
|
16
|
+
import { sharesVendorThroughFallback } from "../contract/judge-independence.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {Record<string, any>} AnyRecord */
|
|
19
|
+
/** @typedef {{id: string, runtimeId: string|null, model: string|null, judgeRuntimeId?: string|null, sameProviderReview?: boolean, dependsOn: string[], requirementIds: string[]}} BriefGraphNode */
|
|
20
|
+
/** @typedef {{from: string, to: string}} BriefGraphEdge */
|
|
21
|
+
/** @typedef {{node: string, prerequisites: string[]}} BriefBlockingNode */
|
|
22
|
+
/** @typedef {{nodes: BriefGraphNode[], edges: BriefGraphEdge[], independent: string[], blocking: BriefBlockingNode[], maxParallel: number, maxConcurrent: Record<string, number>, effectiveConcurrency: number, dispatchableTogether: string[], dispatchNote: string, gaps: string[]}} BriefGraph */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {AnyRecord} contract
|
|
26
|
+
* @param {AnyRecord[]} nodes
|
|
27
|
+
* @returns {BriefGraph}
|
|
28
|
+
*/
|
|
29
|
+
export function buildGraph(contract, nodes) {
|
|
30
|
+
const runtimes = contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
|
|
31
|
+
? /** @type {Record<string, AnyRecord>} */ (contract.runtimes)
|
|
32
|
+
: {};
|
|
33
|
+
const ids = new Set(nodes.map((node) => String(node.id ?? "")));
|
|
34
|
+
/** @type {string[]} */
|
|
35
|
+
const gaps = [];
|
|
36
|
+
/** @type {BriefGraphEdge[]} */
|
|
37
|
+
const edges = [];
|
|
38
|
+
/** @type {BriefGraphNode[]} */
|
|
39
|
+
const graphNodes = nodes.map((node) => {
|
|
40
|
+
const id = String(node.id ?? "");
|
|
41
|
+
const dependsOn = asArray(node.dependsOn).map(String);
|
|
42
|
+
for (const dependency of dependsOn) {
|
|
43
|
+
if (dependency === id) {
|
|
44
|
+
gaps.push(`node ${id} depends on itself`);
|
|
45
|
+
} else if (!ids.has(dependency)) {
|
|
46
|
+
gaps.push(`node ${id} depends on unknown node ${dependency}`);
|
|
47
|
+
} else {
|
|
48
|
+
edges.push({ from: dependency, to: id });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const runtimeId = typeof node.runtime === "string"
|
|
52
|
+
? node.runtime
|
|
53
|
+
: typeof contract.runtimeDefaults?.worker === "string" ? contract.runtimeDefaults.worker : null;
|
|
54
|
+
const runtime = runtimeId !== null && typeof runtimes[runtimeId] === "object" ? runtimes[runtimeId] : null;
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
runtimeId,
|
|
58
|
+
model: runtime && typeof runtime.model === "string" ? runtime.model : null,
|
|
59
|
+
judgeRuntimeId: judgeRuntimeIdOf(contract, node),
|
|
60
|
+
sameProviderReview: sameProviderReviewOf(contract, runtimes, runtimeId, node),
|
|
61
|
+
dependsOn,
|
|
62
|
+
requirementIds: asArray(node.requirementIds).map(String),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const independent = graphNodes.filter((node) => node.dependsOn.length === 0).map((node) => node.id);
|
|
66
|
+
const blocking = graphNodes.filter((node) => node.dependsOn.length > 0).map((node) => ({ node: node.id, prerequisites: node.dependsOn }));
|
|
67
|
+
/** @type {Record<string, number>} */
|
|
68
|
+
const maxConcurrent = {};
|
|
69
|
+
for (const node of graphNodes) {
|
|
70
|
+
if (node.runtimeId === null) continue;
|
|
71
|
+
const runtime = runtimes[node.runtimeId];
|
|
72
|
+
const limit = runtime && Number.isInteger(runtime.maxConcurrent) && runtime.maxConcurrent > 0 ? runtime.maxConcurrent : 1;
|
|
73
|
+
maxConcurrent[node.runtimeId] = maxConcurrent[node.runtimeId] === undefined ? limit : Math.min(maxConcurrent[node.runtimeId], limit);
|
|
74
|
+
}
|
|
75
|
+
const maxParallel = Number.isInteger(contract.maxParallel) && contract.maxParallel > 0 ? contract.maxParallel : 1;
|
|
76
|
+
// Effective concurrency, and the workers that can actually be in flight
|
|
77
|
+
// together, are properties of the graph and the capacities -- not the size of
|
|
78
|
+
// the dependency-independent set. Two independent nodes still cannot run
|
|
79
|
+
// together when maxParallel is 1 or their shared runtime is at its cap.
|
|
80
|
+
const schedule = scheduleUnderCapacity(
|
|
81
|
+
graphNodes.map((node) => ({ id: node.id, runtimeId: node.runtimeId, dependsOn: node.dependsOn })),
|
|
82
|
+
() => 1,
|
|
83
|
+
maxParallel,
|
|
84
|
+
maxConcurrent,
|
|
85
|
+
);
|
|
86
|
+
const effectiveConcurrency = Math.max(1, schedule.peakConcurrency);
|
|
87
|
+
const dispatchableTogether = schedule.peakNodeIds;
|
|
88
|
+
return {
|
|
89
|
+
nodes: graphNodes,
|
|
90
|
+
edges,
|
|
91
|
+
independent,
|
|
92
|
+
blocking,
|
|
93
|
+
maxParallel,
|
|
94
|
+
maxConcurrent,
|
|
95
|
+
effectiveConcurrency,
|
|
96
|
+
dispatchableTogether,
|
|
97
|
+
dispatchNote: dispatchNoteFor(dispatchableTogether, graphNodes.length, maxParallel),
|
|
98
|
+
gaps,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {AnyRecord} contract
|
|
104
|
+
* @param {AnyRecord} node
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
function judgeRuntimeIdOf(contract, node) {
|
|
108
|
+
const gate = node.gate;
|
|
109
|
+
const gateEnabled = gate !== false && gate !== undefined && (gate.enabled === undefined || gate.enabled === true);
|
|
110
|
+
if (!gateEnabled) return null;
|
|
111
|
+
if (typeof gate.runtime === "string") return gate.runtime;
|
|
112
|
+
return typeof contract.runtimeDefaults?.judge === "string" ? contract.runtimeDefaults.judge : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether the frozen contract already marked this node's pairing admitted
|
|
117
|
+
* under same-vendor mode (R20): the worker's declared runtime, or its one-hop
|
|
118
|
+
* fallback, shares a vendor with the judge, and only `judgeIndependence:
|
|
119
|
+
* "same-vendor"` on the contract admits that -- `validateContract` refuses
|
|
120
|
+
* every other same-vendor pairing (primary or fallback) before it can reach a
|
|
121
|
+
* frozen `contract.json`. The fallback is included because it is a real way
|
|
122
|
+
* this node lands a same-provider review, not only the primary pairing: a
|
|
123
|
+
* plan-time surface has no run to read, so it reports every reachable
|
|
124
|
+
* pairing rather than only the one dispatch happens to pick.
|
|
125
|
+
*
|
|
126
|
+
* @param {AnyRecord} contract
|
|
127
|
+
* @param {Record<string, AnyRecord>} runtimes
|
|
128
|
+
* @param {string|null} workerRuntimeId
|
|
129
|
+
* @param {AnyRecord} node
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
function sameProviderReviewOf(contract, runtimes, workerRuntimeId, node) {
|
|
133
|
+
if (contract.judgeIndependence !== "same-vendor") return false;
|
|
134
|
+
const judgeRuntimeId = judgeRuntimeIdOf(contract, node);
|
|
135
|
+
if (workerRuntimeId === null || judgeRuntimeId === null) return false;
|
|
136
|
+
const judgeVendor = runtimes[judgeRuntimeId]?.vendor;
|
|
137
|
+
return sharesVendorThroughFallback(runtimes, workerRuntimeId, judgeVendor);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Why capacity does or does not let two workers run at the same time. The note
|
|
142
|
+
* is the model's honest reading of the limits, so a renderer never has to
|
|
143
|
+
* decide whether an independent pair is "simultaneously dispatchable": with
|
|
144
|
+
* `maxParallel` 1 the answer is always no.
|
|
145
|
+
*
|
|
146
|
+
* @param {string[]} dispatchableTogether
|
|
147
|
+
* @param {number} nodeCount
|
|
148
|
+
* @param {number} maxParallel
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function dispatchNoteFor(dispatchableTogether, nodeCount, maxParallel) {
|
|
152
|
+
if (dispatchableTogether.length >= 2) {
|
|
153
|
+
return `up to ${dispatchableTogether.length} workers run together under maxParallel ${maxParallel} and the per-runtime maxConcurrent caps`;
|
|
154
|
+
}
|
|
155
|
+
if (nodeCount < 2) return "fewer than two nodes are planned";
|
|
156
|
+
if (maxParallel <= 1) return "maxParallel is 1, so dependency-independent nodes are not simultaneously dispatchable";
|
|
157
|
+
return "each assigned runtime's maxConcurrent admits only one worker at a time";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Schedule a dependency graph under a global `maxParallel` ceiling and each
|
|
162
|
+
* runtime's own `maxConcurrent` ceiling, reporting the wall-clock makespan and
|
|
163
|
+
* the peak number of nodes running at once. `durationOf` supplies each node's
|
|
164
|
+
* duration, so scheduling the graph with lower and upper per-node durations
|
|
165
|
+
* turns a per-node range into a plan-level elapsed range. The peak is a
|
|
166
|
+
* property of the graph and the capacities, not of the dependency-independent
|
|
167
|
+
* set.
|
|
168
|
+
*
|
|
169
|
+
* @param {{id: string, runtimeId: string|null, dependsOn: string[]}[]} nodes
|
|
170
|
+
* @param {(node: {id: string}) => number} durationOf
|
|
171
|
+
* @param {number} maxParallel
|
|
172
|
+
* @param {Record<string, number>} maxConcurrent
|
|
173
|
+
* @returns {{makespanMs: number, peakConcurrency: number, peakNodeIds: string[]}}
|
|
174
|
+
*/
|
|
175
|
+
export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurrent) {
|
|
176
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
177
|
+
const remaining = new Map();
|
|
178
|
+
const dependents = new Map();
|
|
179
|
+
for (const node of nodes) {
|
|
180
|
+
const dependencies = node.dependsOn.filter((id) => byId.has(id));
|
|
181
|
+
remaining.set(node.id, dependencies.length);
|
|
182
|
+
for (const dependency of dependencies) dependents.set(dependency, [...(dependents.get(dependency) ?? []), node.id]);
|
|
183
|
+
}
|
|
184
|
+
const running = new Map();
|
|
185
|
+
const runtimeRunning = new Map();
|
|
186
|
+
const done = new Set();
|
|
187
|
+
const limit = Number.isInteger(maxParallel) && maxParallel > 0 ? maxParallel : 1;
|
|
188
|
+
let time = 0;
|
|
189
|
+
let peak = 0;
|
|
190
|
+
/** @type {string[]} */
|
|
191
|
+
let peakNodeIds = [];
|
|
192
|
+
while (done.size < nodes.length) {
|
|
193
|
+
for (const node of nodes) {
|
|
194
|
+
if (done.has(node.id) || running.has(node.id) || (remaining.get(node.id) ?? 0) > 0) continue;
|
|
195
|
+
if (running.size >= limit) break;
|
|
196
|
+
const runtimeKey = node.runtimeId ?? "";
|
|
197
|
+
const width = node.runtimeId !== null && Number.isInteger(maxConcurrent[node.runtimeId]) && maxConcurrent[node.runtimeId] > 0 ? maxConcurrent[node.runtimeId] : 1;
|
|
198
|
+
if ((runtimeRunning.get(runtimeKey) ?? 0) >= width) continue;
|
|
199
|
+
const duration = durationOf(node);
|
|
200
|
+
running.set(node.id, time + (Number.isFinite(duration) ? Math.max(0, duration) : 0));
|
|
201
|
+
runtimeRunning.set(runtimeKey, (runtimeRunning.get(runtimeKey) ?? 0) + 1);
|
|
202
|
+
if (running.size > peak) {
|
|
203
|
+
peak = running.size;
|
|
204
|
+
peakNodeIds = [...running.keys()];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (running.size === 0) break; // a cycle or nothing dispatchable
|
|
208
|
+
time = Math.min(...running.values());
|
|
209
|
+
for (const [id, finish] of [...running]) {
|
|
210
|
+
if (finish !== time) continue;
|
|
211
|
+
running.delete(id);
|
|
212
|
+
runtimeRunning.set(byId.get(id)?.runtimeId ?? "", Math.max(0, (runtimeRunning.get(byId.get(id)?.runtimeId ?? "") ?? 1) - 1));
|
|
213
|
+
done.add(id);
|
|
214
|
+
for (const dependent of dependents.get(id) ?? []) remaining.set(dependent, Math.max(0, (remaining.get(dependent) ?? 1) - 1));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { makespanMs: time, peakConcurrency: peak, peakNodeIds };
|
|
218
|
+
}
|
|
@@ -26,6 +26,7 @@ import { contentDigest, fileDigest } from "../plan/freeze.mjs";
|
|
|
26
26
|
import { parseSpec } from "../plan/spec.mjs";
|
|
27
27
|
import { errorCode } from "../util.mjs";
|
|
28
28
|
import { asArray, bulletLines, firstSentence, riskRows, successCriteriaRows, unique } from "./brief-text.mjs";
|
|
29
|
+
import { buildGraph } from "./campaign-brief-graph.mjs";
|
|
29
30
|
import { projectCampaignDecisions } from "./projection.mjs";
|
|
30
31
|
|
|
31
32
|
/** @typedef {import("../plan/spec.mjs").ParsedSpec} ParsedSpec */
|
|
@@ -41,10 +42,7 @@ import { projectCampaignDecisions } from "./projection.mjs";
|
|
|
41
42
|
/** @typedef {{id: string, proof: string}} BriefProofNode */
|
|
42
43
|
/** @typedef {{requirementId: string, title: string, declaredNodeIds: string[], nodes: BriefProofNode[], state: CoverageState, reasons: string[]}} BriefCoverageRow */
|
|
43
44
|
/** @typedef {{rows: BriefCoverageRow[], unknownIds: string[], unknownDeclared: string[], unknownStamped: string[], gaps: string[], specPath: string, planPath: string, covered: number, uncovered: number, outside: number, traceabilityMissing: number, total: number}} BriefCoverage */
|
|
44
|
-
/** @typedef {
|
|
45
|
-
/** @typedef {{from: string, to: string}} BriefGraphEdge */
|
|
46
|
-
/** @typedef {{node: string, prerequisites: string[]}} BriefBlockingNode */
|
|
47
|
-
/** @typedef {{nodes: BriefGraphNode[], edges: BriefGraphEdge[], independent: string[], blocking: BriefBlockingNode[], maxParallel: number, maxConcurrent: Record<string, number>, effectiveConcurrency: number, dispatchableTogether: string[], dispatchNote: string, gaps: string[]}} BriefGraph */
|
|
45
|
+
/** @typedef {import("./campaign-brief-graph.mjs").BriefGraph} BriefGraph */
|
|
48
46
|
/** @typedef {{status: "range"|"insufficient data", min: number|null, max: number|null, samples: number|null, reason: string|null, sourceRuns: string[], method: string|null, provenance: string|null}} BriefMeasure */
|
|
49
47
|
/** @typedef {{status?: "range"|"insufficient data", min?: number|null, max?: number|null, samples?: number|null, reason?: string|null, sourceRuns?: string[], method?: string|null, provenance?: string|null}} BriefMeasureInput */
|
|
50
48
|
/** @typedef {{cost?: BriefMeasureInput, duration?: BriefMeasureInput, runtimes?: string[], models?: string[], effectiveConcurrency?: number, nodeCount?: number, workerCount?: number, sampleCutoff?: string|null, method?: string[], assumptions?: string[]}} BriefEstimateInput */
|
|
@@ -156,7 +154,12 @@ export function buildBriefModel(options) {
|
|
|
156
154
|
const coverage = buildCoverage(parsedSpec, declarations, contractNodes, specPath, planPath);
|
|
157
155
|
const graph = buildGraph(contract, contractNodes);
|
|
158
156
|
const journalDecisions = options.projection ? projectCampaignDecisions(options.projection) : [];
|
|
159
|
-
|
|
157
|
+
// R16: an operator step the frozen plan carries as an explicit stop
|
|
158
|
+
// (`plan.humanSteps`, `src/plan/human-step.mjs`) is a human decision the
|
|
159
|
+
// spec's own "Human decisions" bullets cannot list, since it is declared on
|
|
160
|
+
// a requirement's constraints instead of that section.
|
|
161
|
+
const humanSteps = asArray(plan.humanSteps);
|
|
162
|
+
const decisions = buildDecisions(parsedSpec, journalDecisions, humanSteps);
|
|
160
163
|
const estimate = buildEstimate(options.estimate, graph, options.usageSampleCutoff ?? null);
|
|
161
164
|
const opening = buildOpening(parsedSpec, coverage, graph, estimate, decisions);
|
|
162
165
|
const identity = buildIdentity({
|
|
@@ -391,166 +394,6 @@ function buildCoverage(parsedSpec, declarations, nodes, specPath, planPath) {
|
|
|
391
394
|
};
|
|
392
395
|
}
|
|
393
396
|
|
|
394
|
-
/**
|
|
395
|
-
* The actual `dependsOn` graph plus the contract's capacity limits. The
|
|
396
|
-
* dependency-independent set is distinct from the workers capacity actually
|
|
397
|
-
* lets dispatch together.
|
|
398
|
-
*
|
|
399
|
-
* @param {AnyRecord} contract
|
|
400
|
-
* @param {AnyRecord[]} nodes
|
|
401
|
-
* @returns {BriefGraph}
|
|
402
|
-
*/
|
|
403
|
-
function buildGraph(contract, nodes) {
|
|
404
|
-
const runtimes = contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
|
|
405
|
-
? /** @type {Record<string, AnyRecord>} */ (contract.runtimes)
|
|
406
|
-
: {};
|
|
407
|
-
const ids = new Set(nodes.map((node) => String(node.id ?? "")));
|
|
408
|
-
/** @type {string[]} */
|
|
409
|
-
const gaps = [];
|
|
410
|
-
/** @type {BriefGraphEdge[]} */
|
|
411
|
-
const edges = [];
|
|
412
|
-
/** @type {BriefGraphNode[]} */
|
|
413
|
-
const graphNodes = nodes.map((node) => {
|
|
414
|
-
const id = String(node.id ?? "");
|
|
415
|
-
const dependsOn = asArray(node.dependsOn).map(String);
|
|
416
|
-
for (const dependency of dependsOn) {
|
|
417
|
-
if (dependency === id) {
|
|
418
|
-
gaps.push(`node ${id} depends on itself`);
|
|
419
|
-
} else if (!ids.has(dependency)) {
|
|
420
|
-
gaps.push(`node ${id} depends on unknown node ${dependency}`);
|
|
421
|
-
} else {
|
|
422
|
-
edges.push({ from: dependency, to: id });
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
const runtimeId = typeof node.runtime === "string"
|
|
426
|
-
? node.runtime
|
|
427
|
-
: typeof contract.runtimeDefaults?.worker === "string" ? contract.runtimeDefaults.worker : null;
|
|
428
|
-
const runtime = runtimeId !== null && typeof runtimes[runtimeId] === "object" ? runtimes[runtimeId] : null;
|
|
429
|
-
return {
|
|
430
|
-
id,
|
|
431
|
-
runtimeId,
|
|
432
|
-
model: runtime && typeof runtime.model === "string" ? runtime.model : null,
|
|
433
|
-
dependsOn,
|
|
434
|
-
requirementIds: asArray(node.requirementIds).map(String),
|
|
435
|
-
};
|
|
436
|
-
});
|
|
437
|
-
const independent = graphNodes.filter((node) => node.dependsOn.length === 0).map((node) => node.id);
|
|
438
|
-
const blocking = graphNodes.filter((node) => node.dependsOn.length > 0).map((node) => ({ node: node.id, prerequisites: node.dependsOn }));
|
|
439
|
-
/** @type {Record<string, number>} */
|
|
440
|
-
const maxConcurrent = {};
|
|
441
|
-
for (const node of graphNodes) {
|
|
442
|
-
if (node.runtimeId === null) continue;
|
|
443
|
-
const runtime = runtimes[node.runtimeId];
|
|
444
|
-
const limit = runtime && Number.isInteger(runtime.maxConcurrent) && runtime.maxConcurrent > 0 ? runtime.maxConcurrent : 1;
|
|
445
|
-
maxConcurrent[node.runtimeId] = maxConcurrent[node.runtimeId] === undefined ? limit : Math.min(maxConcurrent[node.runtimeId], limit);
|
|
446
|
-
}
|
|
447
|
-
const maxParallel = Number.isInteger(contract.maxParallel) && contract.maxParallel > 0 ? contract.maxParallel : 1;
|
|
448
|
-
// Effective concurrency, and the workers that can actually be in flight
|
|
449
|
-
// together, are properties of the graph and the capacities -- not the size of
|
|
450
|
-
// the dependency-independent set. Two independent nodes still cannot run
|
|
451
|
-
// together when maxParallel is 1 or their shared runtime is at its cap.
|
|
452
|
-
const schedule = scheduleUnderCapacity(
|
|
453
|
-
graphNodes.map((node) => ({ id: node.id, runtimeId: node.runtimeId, dependsOn: node.dependsOn })),
|
|
454
|
-
() => 1,
|
|
455
|
-
maxParallel,
|
|
456
|
-
maxConcurrent,
|
|
457
|
-
);
|
|
458
|
-
const effectiveConcurrency = Math.max(1, schedule.peakConcurrency);
|
|
459
|
-
const dispatchableTogether = schedule.peakNodeIds;
|
|
460
|
-
return {
|
|
461
|
-
nodes: graphNodes,
|
|
462
|
-
edges,
|
|
463
|
-
independent,
|
|
464
|
-
blocking,
|
|
465
|
-
maxParallel,
|
|
466
|
-
maxConcurrent,
|
|
467
|
-
effectiveConcurrency,
|
|
468
|
-
dispatchableTogether,
|
|
469
|
-
dispatchNote: dispatchNoteFor(dispatchableTogether, graphNodes.length, maxParallel),
|
|
470
|
-
gaps,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Why capacity does or does not let two workers run at the same time. The note
|
|
476
|
-
* is the model's honest reading of the limits, so a renderer never has to
|
|
477
|
-
* decide whether an independent pair is "simultaneously dispatchable": with
|
|
478
|
-
* `maxParallel` 1 the answer is always no.
|
|
479
|
-
*
|
|
480
|
-
* @param {string[]} dispatchableTogether
|
|
481
|
-
* @param {number} nodeCount
|
|
482
|
-
* @param {number} maxParallel
|
|
483
|
-
* @returns {string}
|
|
484
|
-
*/
|
|
485
|
-
function dispatchNoteFor(dispatchableTogether, nodeCount, maxParallel) {
|
|
486
|
-
if (dispatchableTogether.length >= 2) {
|
|
487
|
-
return `up to ${dispatchableTogether.length} workers run together under maxParallel ${maxParallel} and the per-runtime maxConcurrent caps`;
|
|
488
|
-
}
|
|
489
|
-
if (nodeCount < 2) return "fewer than two nodes are planned";
|
|
490
|
-
if (maxParallel <= 1) return "maxParallel is 1, so dependency-independent nodes are not simultaneously dispatchable";
|
|
491
|
-
return "each assigned runtime's maxConcurrent admits only one worker at a time";
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Schedule a dependency graph under a global `maxParallel` ceiling and each
|
|
496
|
-
* runtime's own `maxConcurrent` ceiling, reporting the wall-clock makespan and
|
|
497
|
-
* the peak number of nodes running at once. `durationOf` supplies each node's
|
|
498
|
-
* duration, so scheduling the graph with lower and upper per-node durations
|
|
499
|
-
* turns a per-node range into a plan-level elapsed range. The peak is a
|
|
500
|
-
* property of the graph and the capacities, not of the dependency-independent
|
|
501
|
-
* set.
|
|
502
|
-
*
|
|
503
|
-
* @param {{id: string, runtimeId: string|null, dependsOn: string[]}[]} nodes
|
|
504
|
-
* @param {(node: {id: string}) => number} durationOf
|
|
505
|
-
* @param {number} maxParallel
|
|
506
|
-
* @param {Record<string, number>} maxConcurrent
|
|
507
|
-
* @returns {{makespanMs: number, peakConcurrency: number, peakNodeIds: string[]}}
|
|
508
|
-
*/
|
|
509
|
-
export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurrent) {
|
|
510
|
-
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
511
|
-
const remaining = new Map();
|
|
512
|
-
const dependents = new Map();
|
|
513
|
-
for (const node of nodes) {
|
|
514
|
-
const dependencies = node.dependsOn.filter((id) => byId.has(id));
|
|
515
|
-
remaining.set(node.id, dependencies.length);
|
|
516
|
-
for (const dependency of dependencies) dependents.set(dependency, [...(dependents.get(dependency) ?? []), node.id]);
|
|
517
|
-
}
|
|
518
|
-
const running = new Map();
|
|
519
|
-
const runtimeRunning = new Map();
|
|
520
|
-
const done = new Set();
|
|
521
|
-
const limit = Number.isInteger(maxParallel) && maxParallel > 0 ? maxParallel : 1;
|
|
522
|
-
let time = 0;
|
|
523
|
-
let peak = 0;
|
|
524
|
-
/** @type {string[]} */
|
|
525
|
-
let peakNodeIds = [];
|
|
526
|
-
while (done.size < nodes.length) {
|
|
527
|
-
for (const node of nodes) {
|
|
528
|
-
if (done.has(node.id) || running.has(node.id) || (remaining.get(node.id) ?? 0) > 0) continue;
|
|
529
|
-
if (running.size >= limit) break;
|
|
530
|
-
const runtimeKey = node.runtimeId ?? "";
|
|
531
|
-
const width = node.runtimeId !== null && Number.isInteger(maxConcurrent[node.runtimeId]) && maxConcurrent[node.runtimeId] > 0 ? maxConcurrent[node.runtimeId] : 1;
|
|
532
|
-
if ((runtimeRunning.get(runtimeKey) ?? 0) >= width) continue;
|
|
533
|
-
const duration = durationOf(node);
|
|
534
|
-
running.set(node.id, time + (Number.isFinite(duration) ? Math.max(0, duration) : 0));
|
|
535
|
-
runtimeRunning.set(runtimeKey, (runtimeRunning.get(runtimeKey) ?? 0) + 1);
|
|
536
|
-
if (running.size > peak) {
|
|
537
|
-
peak = running.size;
|
|
538
|
-
peakNodeIds = [...running.keys()];
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (running.size === 0) break; // a cycle or nothing dispatchable
|
|
542
|
-
time = Math.min(...running.values());
|
|
543
|
-
for (const [id, finish] of [...running]) {
|
|
544
|
-
if (finish !== time) continue;
|
|
545
|
-
running.delete(id);
|
|
546
|
-
runtimeRunning.set(byId.get(id)?.runtimeId ?? "", Math.max(0, (runtimeRunning.get(byId.get(id)?.runtimeId ?? "") ?? 1) - 1));
|
|
547
|
-
done.add(id);
|
|
548
|
-
for (const dependent of dependents.get(id) ?? []) remaining.set(dependent, Math.max(0, (remaining.get(dependent) ?? 1) - 1));
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
return { makespanMs: time, peakConcurrency: peak, peakNodeIds };
|
|
552
|
-
}
|
|
553
|
-
|
|
554
397
|
/**
|
|
555
398
|
* Human decisions and delegated decisions from the explicit spec sections and
|
|
556
399
|
* the active campaign journal projection; risks and planned evals only from
|
|
@@ -561,12 +404,17 @@ export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurr
|
|
|
561
404
|
*
|
|
562
405
|
* @param {ParsedSpec} parsedSpec
|
|
563
406
|
* @param {ProjectedDecision[]} journal
|
|
407
|
+
* @param {AnyRecord[]} [humanSteps] the frozen plan's `humanSteps` (R16)
|
|
564
408
|
* @returns {BriefDecisions}
|
|
565
409
|
*/
|
|
566
|
-
function buildDecisions(parsedSpec, journal) {
|
|
410
|
+
function buildDecisions(parsedSpec, journal, humanSteps = []) {
|
|
567
411
|
/** @param {string} name @returns {string} */
|
|
568
412
|
const section = (name) => parsedSpec.sections.get(name)?.body ?? "";
|
|
569
|
-
const human = [
|
|
413
|
+
const human = [
|
|
414
|
+
...bulletLines(section("human decisions")),
|
|
415
|
+
...bulletLines(section("settled owner decisions")),
|
|
416
|
+
...humanSteps.map((step) => `${step.requirementId}: ${step.step}`),
|
|
417
|
+
];
|
|
570
418
|
const delegated = bulletLines(section("delegable decisions"));
|
|
571
419
|
const evals = bulletLines(section("planned evals"));
|
|
572
420
|
const risks = riskRows(section("risks"));
|
|
@@ -15,7 +15,7 @@ import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
|
15
15
|
import { runsRoot } from "../run/paths.mjs";
|
|
16
16
|
|
|
17
17
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
18
|
-
/** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
|
|
18
|
+
/** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null, sameProviderReview?: boolean|null}} RunNode */
|
|
19
19
|
|
|
20
20
|
/** Flags of `faberun metrics`, declared here so the router only names them. */
|
|
21
21
|
/** @type {import("node:util").ParseArgsOptionsConfig} */
|
|
@@ -153,6 +153,7 @@ function readLedgerNodeSnapshots(path) {
|
|
|
153
153
|
attempt: typeof object.attempt === "number" ? object.attempt : null,
|
|
154
154
|
revisions: typeof object.revisions === "number" ? object.revisions : null,
|
|
155
155
|
review: typeof object.review === "string" ? object.review : null,
|
|
156
|
+
sameProviderReview: typeof object.sameProviderReview === "boolean" ? object.sameProviderReview : null,
|
|
156
157
|
}];
|
|
157
158
|
});
|
|
158
159
|
} catch {
|
|
@@ -187,6 +188,7 @@ export function readMetricNodeSnapshots(runDir) {
|
|
|
187
188
|
attempt: typeof record.attempt === "number" ? record.attempt : null,
|
|
188
189
|
revisions: typeof record.revisions === "number" ? record.revisions : null,
|
|
189
190
|
review: typeof record.review === "string" ? record.review : null,
|
|
191
|
+
sameProviderReview: typeof record.sameProviderReview === "boolean" ? record.sameProviderReview : null,
|
|
190
192
|
});
|
|
191
193
|
}
|
|
192
194
|
return nodes;
|
package/src/cli/brand.mjs
CHANGED
|
@@ -192,6 +192,7 @@ export function renderUsage() {
|
|
|
192
192
|
"update [--check] [--json]",
|
|
193
193
|
"project <new-path> [--from <old-path>]",
|
|
194
194
|
"migrate [--cwd <dir>]",
|
|
195
|
+
"prune [--cwd <dir>] [--parked] [--json]",
|
|
195
196
|
"models [--probe] [--json]",
|
|
196
197
|
"next [--cwd <dir>] [--json]",
|
|
197
198
|
"bulk-read --question <text> --paths <a,b,c> [--json]",
|
package/src/cli/launch.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { readRunNodes } from "../run/node-store.mjs";
|
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import { validateContract } from "../contract/index.mjs";
|
|
26
26
|
import { runDirectory } from "../run/paths.mjs";
|
|
27
|
+
import { livePreflightCeilingSec } from "../engine/live-preflight.mjs";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The file a detached child is spawned as. It must be the CLI and not this
|
|
@@ -94,7 +95,7 @@ export function detachArgv(argv, options = {}) {
|
|
|
94
95
|
* @param {number} [timeoutMs]
|
|
95
96
|
* @returns {Promise<BootstrapRecord>}
|
|
96
97
|
*/
|
|
97
|
-
export async function waitForBootstrap(runDir, pid, child = null, timeoutMs =
|
|
98
|
+
export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bootstrapReadyTimeoutMs()) {
|
|
98
99
|
const deadline = Date.now() + timeoutMs;
|
|
99
100
|
const nonce = child?.bootstrapNonce;
|
|
100
101
|
if (!nonce) throw new Error(`detached bootstrap has no start nonce for pid ${pid}`);
|
|
@@ -157,6 +158,18 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30
|
|
|
157
158
|
cleanupBootstrapAttempts(runDir);
|
|
158
159
|
throw new Error(`detached bootstrap did not become ready for pid ${pid}`);
|
|
159
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The controller answers ready only after the dispatch gate's live preflight,
|
|
163
|
+
* so the launcher waits for that ask on top of its own 30s start-up margin.
|
|
164
|
+
* Measured 2026-09-27: with a fixed 30s wait, a gate asking Opus at `xhigh`
|
|
165
|
+
* made `run --detach` and `plan --detach` report "did not become ready" for
|
|
166
|
+
* controllers that then came up and ran.
|
|
167
|
+
*
|
|
168
|
+
* @returns {number}
|
|
169
|
+
*/
|
|
170
|
+
export function bootstrapReadyTimeoutMs() {
|
|
171
|
+
return 30_000 + livePreflightCeilingSec() * 1000;
|
|
172
|
+
}
|
|
160
173
|
/**
|
|
161
174
|
* @param {string} runDir
|
|
162
175
|
* @param {BootstrapRecord} bootstrap
|