faberun 0.25.0 → 0.26.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/package.json +1 -1
- package/skills/faberun/references/contract.md +34 -28
- 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/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/contract/task-packet.mjs +4 -1
- package/src/contract/worker-result.mjs +35 -4
- 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 +29 -24
- package/src/engine/resume.mjs +49 -12
- 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.0",
|
|
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": [
|
|
@@ -140,38 +140,44 @@ missing something, never repository-wide exploration. Bounded: 32 KiB total,
|
|
|
140
140
|
4 KiB summary, 32 entries each in `changedFiles`/`verification`/`artifacts`
|
|
141
141
|
(16 in `missingContext`), 2 KiB per entry (16 KiB per artifact). Unknown
|
|
142
142
|
provider-added fields are dropped; missing/malformed canonical fields are
|
|
143
|
-
rejected (`worker-result.mjs`). A discovery node
|
|
144
|
-
one `artifacts` entry
|
|
143
|
+
rejected (`worker-result.mjs`). A discovery node without `readFiles` returns
|
|
144
|
+
`done` with one `artifacts` entry, the next node's execution packet; one closed
|
|
145
|
+
to its `readFiles` (every planning stage) delivers through `output`, `artifacts`
|
|
146
|
+
`[]`.
|
|
145
147
|
|
|
146
148
|
## Runtimes and routing
|
|
147
149
|
|
|
148
150
|
Resolve a worker as `nodes[].runtime`, then `runtimeDefaults.worker`; a judge
|
|
149
|
-
as `nodes[].gate.runtime`, then `runtimeDefaults.judge
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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`).
|
|
157
165
|
|
|
158
166
|
`harness` names the adapter that runs the turn (`claude`, `codex`, `agy`,
|
|
159
|
-
`dsh`, `zcode`, `exec-jsonl`, `replay`) and `model` what it asks; the two
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
it; ids take letters, numbers, dot, underscore, dash only. Vendor is
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
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
|
|
174
|
-
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.
|
|
175
181
|
|
|
176
182
|
An optional `runtimes[<id>].pricing` object declares `inputPerMTok`,
|
|
177
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]",
|