faberun 0.14.0 → 0.16.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/operations.md +22 -11
- package/src/cli/plan.mjs +40 -1
- package/src/cli.mjs +1 -0
- package/src/contract/runtime.mjs +50 -2
- package/src/contract/verification.mjs +26 -10
- package/src/engine/assignment.mjs +33 -6
- package/src/engine/backoff.mjs +11 -5
- package/src/engine/cancel.mjs +20 -6
- package/src/engine/failover.mjs +62 -1
- package/src/engine/judge-gate.mjs +63 -5
- package/src/engine/lifecycle.mjs +17 -4
- package/src/engine/mutation.mjs +47 -7
- package/src/engine/resume.mjs +1 -1
- package/src/engine/runtime-discovery.mjs +44 -7
- package/src/engine/scheduler.mjs +19 -23
- package/src/engine/scope.mjs +108 -6
- package/src/engine/settle.mjs +19 -8
- package/src/host/preflight.mjs +5 -3
- package/src/notify/index.mjs +66 -32
- package/src/notify/session.mjs +240 -0
- package/src/plan/pipeline.mjs +215 -22
- package/src/plan/repo-facts.mjs +2 -0
- package/src/plan/routing.mjs +151 -33
- package/src/plan/sizing.mjs +33 -0
- package/src/repo/worktree.mjs +28 -0
- package/src/report/final.mjs +2 -1
- package/src/report/locale.mjs +164 -0
- package/src/report/message.mjs +602 -0
- package/src/report/packet-repetition.mjs +121 -0
- package/src/report/progress.mjs +33 -153
- package/src/report/render.mjs +3 -3
- package/src/seat/tmux.mjs +12 -3
package/src/plan/routing.mjs
CHANGED
|
@@ -5,26 +5,44 @@
|
|
|
5
5
|
* resolves a *contract's* already-declared runtimes; a plan's draft node
|
|
6
6
|
* never names one (the constitution reserves model choice to runtimes,
|
|
7
7
|
* runtimeDefaults, or an explicit node override) — this module is what turns
|
|
8
|
-
* a taskKind/riskTier classification into one of those three.
|
|
8
|
+
* a taskKind/riskTier classification into one of those three. Each rule names
|
|
9
|
+
* the strategy that consumes its `prefer` list, and every assignment records
|
|
10
|
+
* the strategy that was applied and the reason for the choice — `declared`
|
|
11
|
+
* when an operator instruction prevailed, since that outranks every strategy.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
|
-
import {
|
|
14
|
+
import { ROUTING_STRATEGIES } from "../contract/runtime.mjs";
|
|
15
|
+
import { cheapest, isRuntimeAvailable, strongest } from "../engine/runtime-discovery.mjs";
|
|
12
16
|
|
|
13
|
-
/** @typedef {
|
|
17
|
+
/** @typedef {import("../contract/runtime.mjs").RoutingStrategy} RoutingStrategy */
|
|
18
|
+
/** @typedef {RoutingStrategy|"declared"} AppliedStrategy */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The plan's copy of the discovery catalogue record: only what the harness de
|
|
22
|
+
* facto reports, with the moment each datum was observed. The observables are
|
|
23
|
+
* null where the harness exposes nothing -- never zero and never full
|
|
24
|
+
* allowance, so a runtime that reports nothing cannot look rested -- and an
|
|
25
|
+
* observation older than its own window reads as unknown at the one shared
|
|
26
|
+
* reader, `isRuntimeAvailable` in runtime-discovery.mjs.
|
|
27
|
+
* @typedef {{available: boolean, exhaustedUntil: string|null, observedAt?: string|null, window?: string|null, remaining?: number|null, [key: string]: unknown}} RoutingAvailability
|
|
28
|
+
*/
|
|
14
29
|
/** @typedef {{vendor: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
|
|
15
30
|
/** @typedef {{id: string, taskKind?: string, riskTier?: string}} RoutingNode */
|
|
16
31
|
/** @typedef {{taskKind?: string, riskTier?: string}} RoutingWhen */
|
|
17
|
-
/** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge"}} RoutingRule */
|
|
32
|
+
/** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge", strategy?: RoutingStrategy}} RoutingRule */
|
|
18
33
|
/** @typedef {{worker?: string, judge?: string}} RoutingRoleMap */
|
|
19
34
|
/** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>}} RoutingConfig */
|
|
20
|
-
/** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}}} RoutingAssignment */
|
|
35
|
+
/** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}, strategy: {worker: AppliedStrategy|null, judge: AppliedStrategy|null}, reason: {worker: string, judge: string}}} RoutingAssignment */
|
|
21
36
|
/** @typedef {{nodeId: string, role: "worker"|"judge", rule: string}} RoutingUnmet */
|
|
22
37
|
/** @typedef {{assignments: Record<string, RoutingAssignment>, unmet: RoutingUnmet[]}} RoutingResult */
|
|
23
38
|
|
|
24
39
|
/**
|
|
25
40
|
* @param {RoutingNode[]} nodes
|
|
26
41
|
* @param {RoutingConfig} config
|
|
27
|
-
* @param {{partial?: boolean}} [options]
|
|
42
|
+
* @param {{partial?: boolean, previous?: Record<string, RoutingRoleMap>}} [options]
|
|
43
|
+
* `previous` carries the per-node role assignment the last routing pass made,
|
|
44
|
+
* the datum the `attempt-affinity` strategy prefers; absent, that strategy is
|
|
45
|
+
* inert and the remaining rules decide.
|
|
28
46
|
* @returns {RoutingResult}
|
|
29
47
|
*/
|
|
30
48
|
export function resolveRuntimes(nodes, config, options = {}) {
|
|
@@ -33,6 +51,15 @@ export function resolveRuntimes(nodes, config, options = {}) {
|
|
|
33
51
|
const availability = config.availability ?? {};
|
|
34
52
|
const runtimeDefaults = config.runtimeDefaults ?? {};
|
|
35
53
|
const overrides = config.overrides ?? {};
|
|
54
|
+
const previous = options.previous ?? {};
|
|
55
|
+
|
|
56
|
+
// A strategy name outside the vocabulary is an authored-table defect, not an
|
|
57
|
+
// unobservable datum: it fails fast, before any node is resolved.
|
|
58
|
+
for (const row of table) {
|
|
59
|
+
if (row.strategy !== undefined && !ROUTING_STRATEGIES.has(row.strategy)) {
|
|
60
|
+
throw new TypeError(`routing rule ${ruleLabel(row)} names unknown strategy ${row.strategy}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
36
63
|
|
|
37
64
|
/** @type {Record<string, RoutingAssignment>} */
|
|
38
65
|
const assignments = {};
|
|
@@ -41,15 +68,21 @@ export function resolveRuntimes(nodes, config, options = {}) {
|
|
|
41
68
|
|
|
42
69
|
for (const node of nodes) {
|
|
43
70
|
const override = overrides[node.id] ?? {};
|
|
44
|
-
const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS });
|
|
71
|
+
const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS, previousId: previous[node.id]?.worker });
|
|
45
72
|
if (worker.runtimeId === null) unmet.push({ nodeId: node.id, role: "worker", rule: worker.rule });
|
|
46
73
|
// The judge's forbidden vendors follow the worker runtime that was
|
|
47
74
|
// actually chosen, never the row that named it — a worker unmet leaves
|
|
48
75
|
// nothing to conflict with, so the judge resolves without restriction.
|
|
49
76
|
const forbiddenVendors = worker.runtimeId ? forbiddenJudgeVendors(worker.runtimeId, runtimes) : EMPTY_VENDORS;
|
|
50
|
-
const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors });
|
|
77
|
+
const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors, previousId: previous[node.id]?.judge });
|
|
51
78
|
if (judge.runtimeId === null) unmet.push({ nodeId: node.id, role: "judge", rule: judge.rule });
|
|
52
|
-
assignments[node.id] = {
|
|
79
|
+
assignments[node.id] = {
|
|
80
|
+
worker: worker.runtimeId,
|
|
81
|
+
judge: judge.runtimeId,
|
|
82
|
+
rule: { worker: worker.rule, judge: judge.rule },
|
|
83
|
+
strategy: { worker: worker.strategy, judge: judge.strategy },
|
|
84
|
+
reason: { worker: worker.reason, judge: judge.reason },
|
|
85
|
+
};
|
|
53
86
|
}
|
|
54
87
|
|
|
55
88
|
if (unmet.length && options.partial !== true) {
|
|
@@ -66,44 +99,136 @@ const EMPTY_VENDORS = Object.freeze(new Set());
|
|
|
66
99
|
/**
|
|
67
100
|
* Precedence for one role on one node: an explicit node override, then the
|
|
68
101
|
* operator's runtimeDefaults, then the first table row whose `when` matches
|
|
69
|
-
* this node's classification, then plain discovery. A
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
102
|
+
* this node's classification, then plain discovery. A declaration outranks
|
|
103
|
+
* every strategy — it is recorded as `declared`, never as a strategy outcome —
|
|
104
|
+
* and a row or default that names an unavailable or vendor-forbidden runtime
|
|
105
|
+
* is unmet by that rule — it does not fall through to a lower-precedence
|
|
106
|
+
* source, since falling through would silently discard an explicit
|
|
107
|
+
* declaration; only the candidates *within* a row's `prefer` list, and within
|
|
108
|
+
* discovery, are skipped for exhaustion or vendor conflict.
|
|
75
109
|
*
|
|
76
110
|
* @param {RoutingNode} node
|
|
77
111
|
* @param {"worker"|"judge"} role
|
|
78
|
-
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string
|
|
79
|
-
* @returns {{runtimeId: string|null, rule: string}}
|
|
112
|
+
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string>, previousId: string|undefined}} context
|
|
113
|
+
* @returns {{runtimeId: string|null, rule: string, strategy: AppliedStrategy|null, reason: string}}
|
|
80
114
|
*/
|
|
81
115
|
function resolveRole(node, role, context) {
|
|
82
|
-
const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors } = context;
|
|
116
|
+
const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors, previousId } = context;
|
|
83
117
|
|
|
84
118
|
if (override[role] !== undefined) {
|
|
85
119
|
const id = override[role];
|
|
86
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null,
|
|
122
|
+
rule: "override",
|
|
123
|
+
strategy: "declared",
|
|
124
|
+
reason: `operator override on node ${node.id}`,
|
|
125
|
+
};
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
if (runtimeDefaults[role] !== undefined) {
|
|
90
129
|
const id = runtimeDefaults[role];
|
|
91
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null,
|
|
132
|
+
rule: "runtimeDefaults",
|
|
133
|
+
strategy: "declared",
|
|
134
|
+
reason: "operator runtimeDefaults",
|
|
135
|
+
};
|
|
92
136
|
}
|
|
93
137
|
|
|
94
138
|
const row = table.find((candidate) => candidate.role === role
|
|
95
139
|
&& (candidate.when.taskKind === undefined || candidate.when.taskKind === node.taskKind)
|
|
96
140
|
&& (candidate.when.riskTier === undefined || candidate.when.riskTier === node.riskTier));
|
|
97
141
|
if (row) {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
return { runtimeId:
|
|
142
|
+
const strategy = row.strategy ?? "priority";
|
|
143
|
+
const decision = chooseByStrategy(strategy, row.prefer, { runtimes, availability, forbiddenVendors, previousId });
|
|
144
|
+
return { runtimeId: decision.runtimeId, rule: ruleLabel(row), strategy, reason: decision.reason };
|
|
101
145
|
}
|
|
102
146
|
|
|
103
147
|
const discovered = role === "worker"
|
|
104
148
|
? cheapestAvailable(runtimes, availability, forbiddenVendors)
|
|
105
149
|
: strongestAvailable(runtimes, availability, forbiddenVendors);
|
|
106
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
runtimeId: discovered,
|
|
152
|
+
rule: "discovery",
|
|
153
|
+
strategy: role === "worker" ? "cost" : "priority",
|
|
154
|
+
reason: role === "worker" ? "discovery: cheapest available runtime" : "discovery: strongest available runtime",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One strategy's choice over a row's `prefer` list -- attempt-affinity's
|
|
160
|
+
* candidate is the previous attempt's runtime, which the list need not name.
|
|
161
|
+
* A strategy whose datum a
|
|
162
|
+
* candidate does not expose is inert for that candidate — it cannot choose it,
|
|
163
|
+
* and it never raises; inert for the whole row when no candidate exposes it,
|
|
164
|
+
* at which point the prefer list's own order decides. The reason says which
|
|
165
|
+
* of the two happened, because a recorded strategy that fell back must read
|
|
166
|
+
* differently from one that decided.
|
|
167
|
+
*
|
|
168
|
+
* @param {RoutingStrategy} strategy
|
|
169
|
+
* @param {string[]} prefer
|
|
170
|
+
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, forbiddenVendors: ReadonlySet<string>, previousId: string|undefined}} context
|
|
171
|
+
* @returns {{runtimeId: string|null, reason: string}}
|
|
172
|
+
*/
|
|
173
|
+
function chooseByStrategy(strategy, prefer, context) {
|
|
174
|
+
const { runtimes, availability, forbiddenVendors, previousId } = context;
|
|
175
|
+
const candidates = prefer.map((id) => ({ id, admissible: admits(id, runtimes, availability, forbiddenVendors) }));
|
|
176
|
+
const admissible = candidates.filter((candidate) => candidate.admissible);
|
|
177
|
+
if (admissible.length === 0) return { runtimeId: null, reason: "no admissible candidate in the prefer list" };
|
|
178
|
+
|
|
179
|
+
if (strategy === "attempt-affinity") {
|
|
180
|
+
if (previousId === undefined) {
|
|
181
|
+
return { runtimeId: admissible[0].id, reason: "attempt-affinity inert: no previous attempt recorded; prefer order decided" };
|
|
182
|
+
}
|
|
183
|
+
// Affinity's candidate is the previous attempt's runtime itself, even
|
|
184
|
+
// where the prefer list does not name it -- reusing it is the strategy's
|
|
185
|
+
// whole point. It yields by name whenever it would violate availability
|
|
186
|
+
// or vendor distinction, the yields `admits` already speaks.
|
|
187
|
+
if (admits(previousId, runtimes, availability, forbiddenVendors)) {
|
|
188
|
+
return { runtimeId: previousId, reason: `attempt-affinity: previous attempt's runtime ${previousId} still admissible` };
|
|
189
|
+
}
|
|
190
|
+
return { runtimeId: admissible[0].id, reason: `attempt-affinity yielded: ${previousId} ${blockedWhy(previousId, runtimes, availability, forbiddenVendors)}; prefer order decided` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (strategy === "cost") {
|
|
194
|
+
const ranked = admissible.filter((candidate) => typeof runtimes[candidate.id]?.costRank === "number");
|
|
195
|
+
if (ranked.length === 0) {
|
|
196
|
+
return { runtimeId: admissible[0].id, reason: "cost inert: no admissible candidate exposes costRank; prefer order decided" };
|
|
197
|
+
}
|
|
198
|
+
/** @param {{id: string, admissible: boolean}} candidate */
|
|
199
|
+
const costRank = (candidate) => /** @type {number} */ (runtimes[candidate.id]?.costRank);
|
|
200
|
+
const winner = ranked.reduce((best, candidate) => (costRank(candidate) < costRank(best) ? candidate : best));
|
|
201
|
+
return { runtimeId: winner.id, reason: `cost: lowest costRank ${costRank(winner)} among ranked candidates` };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (strategy === "reset-proximity") {
|
|
205
|
+
const measured = admissible.filter((candidate) => typeof availability[candidate.id]?.remaining === "number");
|
|
206
|
+
if (measured.length === 0) {
|
|
207
|
+
return { runtimeId: admissible[0].id, reason: "reset-proximity inert: no admissible candidate exposes remaining; prefer order decided" };
|
|
208
|
+
}
|
|
209
|
+
// The window nearest its reset is spent first, so a reset never wastes
|
|
210
|
+
// allowance; a runtime exposing nothing is unrankable, not free.
|
|
211
|
+
const winner = measured.reduce((best, candidate) => (/** @type {number} */ (availability[candidate.id].remaining) < /** @type {number} */ (availability[best.id].remaining) ? candidate : best));
|
|
212
|
+
return { runtimeId: winner.id, reason: `reset-proximity: least remaining allowance (${/** @type {number} */ (availability[winner.id].remaining)})` };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { runtimeId: admissible[0].id, reason: "priority: first admissible of the prefer list" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Why one runtime fails `admits`, in words a recorded reason can quote.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} id
|
|
222
|
+
* @param {Record<string, RoutingRuntime>} runtimes
|
|
223
|
+
* @param {Record<string, RoutingAvailability>} availability
|
|
224
|
+
* @param {ReadonlySet<string>} forbiddenVendors
|
|
225
|
+
* @returns {string}
|
|
226
|
+
*/
|
|
227
|
+
function blockedWhy(id, runtimes, availability, forbiddenVendors) {
|
|
228
|
+
const runtime = runtimes[id];
|
|
229
|
+
if (!runtime) return "is not declared in runtimes";
|
|
230
|
+
if (forbiddenVendors.has(runtime.vendor)) return `carries forbidden vendor ${runtime.vendor}`;
|
|
231
|
+
return "is unavailable";
|
|
107
232
|
}
|
|
108
233
|
|
|
109
234
|
/**
|
|
@@ -117,14 +242,7 @@ function admits(id, runtimes, availability, forbiddenVendors) {
|
|
|
117
242
|
const runtime = runtimes[id];
|
|
118
243
|
if (!runtime) return false;
|
|
119
244
|
if (forbiddenVendors.has(runtime.vendor)) return false;
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** @param {RoutingAvailability|undefined} entry @returns {boolean} */
|
|
124
|
-
function isAvailable(entry) {
|
|
125
|
-
if (!entry) return false;
|
|
126
|
-
if (entry.available === true) return !entry.exhaustedUntil || Date.parse(entry.exhaustedUntil) <= Date.now();
|
|
127
|
-
return Boolean(entry.exhaustedUntil && Date.parse(entry.exhaustedUntil) <= Date.now());
|
|
245
|
+
return isRuntimeAvailable(availability[id]);
|
|
128
246
|
}
|
|
129
247
|
|
|
130
248
|
/**
|
|
@@ -165,7 +283,7 @@ function ruleLabel(row) {
|
|
|
165
283
|
function candidateEntries(runtimes, availability, forbiddenVendors) {
|
|
166
284
|
return Object.entries(runtimes)
|
|
167
285
|
.map(([id, runtime], order) => ({ id, runtime, order }))
|
|
168
|
-
.filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) &&
|
|
286
|
+
.filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) && isRuntimeAvailable(availability[id]));
|
|
169
287
|
}
|
|
170
288
|
|
|
171
289
|
/**
|
package/src/plan/sizing.mjs
CHANGED
|
@@ -432,6 +432,39 @@ function markParallelisable(nodes, transformations) {
|
|
|
432
432
|
});
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Ceiling on the concurrency a frozen plan may declare. Not a measurement: no
|
|
437
|
+
* one has measured how many concurrent workers this or any host survives, and
|
|
438
|
+
* the only evidence on hand is the wrong direction — three OOM kills on the
|
|
439
|
+
* planning host on 2026-09-21. So the ceiling is the largest value a contract
|
|
440
|
+
* in this repository's own record has ever run with: of the 58 stored
|
|
441
|
+
* contracts, 44 ran at 1 and 14 at 2, none higher. Raise it when someone
|
|
442
|
+
* measures a host surviving more, not before.
|
|
443
|
+
*/
|
|
444
|
+
const MAX_PROVEN_PARALLELISM = 2;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* How many nodes a sized plan may have in flight at once: the nodes
|
|
448
|
+
* `markParallelisable` marked, floored at 1 and capped at
|
|
449
|
+
* `MAX_PROVEN_PARALLELISM`. The mark is transitive by construction — a marked
|
|
450
|
+
* node depends on nothing, nothing depends on it, and its writeFiles is
|
|
451
|
+
* disjoint from *every* other node's, so any two marked nodes are independent
|
|
452
|
+
* of each other too and can all run together. The count is therefore the
|
|
453
|
+
* concurrency sizing proved the plan has work for: above it a slot only ever
|
|
454
|
+
* idles, and a plan with one marked node, or none, is 1 — the serial number a
|
|
455
|
+
* contract carries when it says nothing.
|
|
456
|
+
*
|
|
457
|
+
* `parallel` is a node-level mark and no contract node accepts it, so this
|
|
458
|
+
* number is the only way sizing's conclusion survives into a frozen contract
|
|
459
|
+
* (as `maxParallel`).
|
|
460
|
+
*
|
|
461
|
+
* @param {Plan} plan
|
|
462
|
+
* @returns {number}
|
|
463
|
+
*/
|
|
464
|
+
export function provenParallelism(plan) {
|
|
465
|
+
return Math.min(MAX_PROVEN_PARALLELISM, Math.max(1, plan.nodes.filter((node) => node.parallel === true).length));
|
|
466
|
+
}
|
|
467
|
+
|
|
435
468
|
/**
|
|
436
469
|
* The longest dependsOn chain in the graph, counted in nodes. Sizing runs on
|
|
437
470
|
* a model-drafted, not-yet-validated plan, so a dependsOn cycle is possible
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -104,6 +104,11 @@ export function candidateRefName(runId) {
|
|
|
104
104
|
return `refs/faberun/${runId}/candidate`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** @param {string} runId @param {string} nodeId @returns {string} */
|
|
108
|
+
export function preservedRefName(runId, nodeId) {
|
|
109
|
+
return `refs/faberun/${runId}/preserved/${nodeId}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
107
112
|
/** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
|
|
108
113
|
function attemptBranchName(runId, nodeId, attempt) {
|
|
109
114
|
return `faberun/${runId}/${nodeId}/${attempt}`;
|
|
@@ -168,6 +173,29 @@ export function createRunRef(repo, runId, head) {
|
|
|
168
173
|
return ref;
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Keep one node's integrated commit reachable after cancel releases the run
|
|
178
|
+
* ref and the attempt branches: without a ref of its own the commit is
|
|
179
|
+
* garbage the next `git gc` collects, and the persisted snapshot alone cannot
|
|
180
|
+
* bring a pruned object back.
|
|
181
|
+
*
|
|
182
|
+
* Idempotent by the same contract `deleteRef` and `removeWorktree` honour:
|
|
183
|
+
* cancel can legitimately run twice (a retry after a partial first pass, or
|
|
184
|
+
* the operator repeating it), so a preserved ref that already names this
|
|
185
|
+
* exact commit is done, not a conflict — the second creation neither fails
|
|
186
|
+
* nor moves the target. A ref left at a different sha moves to the commit
|
|
187
|
+
* handed in now.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} repo @param {string} runId @param {string} nodeId @param {string|null|undefined} sha @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function createPreservedRef(repo, runId, nodeId, sha) {
|
|
192
|
+
if (!sha) throw Object.assign(new Error("a preserved ref needs an integrated commit"), { code: "git_head_required" });
|
|
193
|
+
const ref = preservedRefName(runId, nodeId);
|
|
194
|
+
if (gitHead(repo, ref) === sha) return ref;
|
|
195
|
+
runGit(["-C", repo, "update-ref", ref, sha]);
|
|
196
|
+
return ref;
|
|
197
|
+
}
|
|
198
|
+
|
|
171
199
|
/**
|
|
172
200
|
* @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string, declaredReads?: string[]}} args
|
|
173
201
|
* `base` cuts the new branch from a sealed sha instead of the run ref tip —
|
package/src/report/final.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { basename, join } from "node:path";
|
|
|
11
11
|
import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
12
12
|
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
13
13
|
import { MARK, fit, roleCosts, statusNote, writeStatusArtifacts } from "./render.mjs";
|
|
14
|
+
import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
|
|
14
15
|
import { unlinkSync } from "node:fs";
|
|
15
16
|
|
|
16
17
|
/** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
|
|
@@ -130,7 +131,7 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
130
131
|
]));
|
|
131
132
|
}
|
|
132
133
|
const roles = roleCosts(nodes);
|
|
133
|
-
lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)} · read ${compactTokens(totals.declaredReadBytes)}`);
|
|
134
|
+
lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)} · read ${compactTokens(totals.declaredReadBytes)}${packetRepetitionNote(packetRepetitionByNode(runDir, nodes))}`);
|
|
134
135
|
return `${lines.join("\n")}\n`;
|
|
135
136
|
}
|
|
136
137
|
/**
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language of a notification, and the labels it is written in.
|
|
3
|
+
*
|
|
4
|
+
* The controller never sees the operator's conversation with a harness; it
|
|
5
|
+
* sees what the operator wrote for faberun: the campaign goal, the journal
|
|
6
|
+
* notes and the objectives the plan gave each node. Those are the operator's
|
|
7
|
+
* prompts to this product, and a message that answers them in the same
|
|
8
|
+
* language reads as a reply rather than a log line. Detection is a count of
|
|
9
|
+
* function words in the two languages this product's operators write in;
|
|
10
|
+
* anything else, or nothing to read, is English. Quoted content -- the
|
|
11
|
+
* worker's own words, a judge finding -- keeps the language it was written
|
|
12
|
+
* in, because translating a quotation would make it stop being one.
|
|
13
|
+
*
|
|
14
|
+
* It is a module apart from `message.mjs` so the layout and the wording can
|
|
15
|
+
* change independently: a new label is one line here, a new line is one
|
|
16
|
+
* function there.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @typedef {"en"|"pt"} Language */
|
|
20
|
+
|
|
21
|
+
// `a`, `do` and `no` are left out on purpose: each is also an English word,
|
|
22
|
+
// and one article in an English sentence must not read as Portuguese.
|
|
23
|
+
const PORTUGUESE = new Set(["o", "e", "de", "da", "na", "que", "não", "nao", "para", "com", "uma", "um", "em", "os", "as", "dos", "das", "é", "se", "por", "mais", "como", "mas", "ao", "ou", "quando", "já", "também", "ser", "sem", "até", "cada", "nunca", "está", "são", "foi", "pelo", "pela", "nos", "nas", "seu", "sua", "isso", "esse", "essa", "ele", "ela"]);
|
|
24
|
+
const ENGLISH = new Set(["the", "and", "of", "to", "in", "is", "for", "with", "that", "on", "as", "are", "this", "it", "be", "by", "from", "or", "an", "not", "at", "when", "every", "never", "before", "after", "its", "into", "than", "which", "while", "each", "was", "has", "have"]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The language of the first group of samples that carries any function word
|
|
28
|
+
* at all, English when none does. Groups are passed in order of authorship:
|
|
29
|
+
* the campaign goal is the operator's own sentence, the journal notes are the
|
|
30
|
+
* orchestrating session's, the node objectives are the planner's -- and a
|
|
31
|
+
* plan written by a model in English must not outvote the person who typed
|
|
32
|
+
* the goal in Portuguese (measured 2026-09-21: two node objectives held 40
|
|
33
|
+
* English function words against a Portuguese goal's 19).
|
|
34
|
+
*
|
|
35
|
+
* @param {...readonly (string|null|undefined)[]} groups
|
|
36
|
+
* @returns {Language}
|
|
37
|
+
*/
|
|
38
|
+
export function detectLanguage(...groups) {
|
|
39
|
+
for (const samples of groups) {
|
|
40
|
+
let portuguese = 0;
|
|
41
|
+
let english = 0;
|
|
42
|
+
for (const sample of samples) {
|
|
43
|
+
if (typeof sample !== "string") continue;
|
|
44
|
+
for (const word of sample.toLowerCase().split(/[^\p{L}]+/u)) {
|
|
45
|
+
if (PORTUGUESE.has(word)) portuguese += 1;
|
|
46
|
+
else if (ENGLISH.has(word)) english += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (portuguese + english > 0) return portuguese > english ? "pt" : "en";
|
|
50
|
+
}
|
|
51
|
+
return "en";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every phrase the message renders, per language. Node ids, run ids, model
|
|
56
|
+
* names and quoted text are never here: they are data, not wording.
|
|
57
|
+
*
|
|
58
|
+
* @type {Record<Language, Record<string, string>>}
|
|
59
|
+
*/
|
|
60
|
+
const LABELS = {
|
|
61
|
+
en: {
|
|
62
|
+
doneIn: "done in",
|
|
63
|
+
running: "running",
|
|
64
|
+
pending: "pending",
|
|
65
|
+
state: "state",
|
|
66
|
+
nothingDelivered: "nothing delivered",
|
|
67
|
+
"no-op": "no-op after",
|
|
68
|
+
failedAfter: "failed after",
|
|
69
|
+
blockedAfter: "blocked after",
|
|
70
|
+
exhaustedAfter: "exhausted after",
|
|
71
|
+
stalledAfter: "stalled after",
|
|
72
|
+
canceledAfter: "canceled after",
|
|
73
|
+
needsYou: "needs you",
|
|
74
|
+
attempt: "attempt",
|
|
75
|
+
run: "run",
|
|
76
|
+
done: "done",
|
|
77
|
+
asked: "asked",
|
|
78
|
+
did: "done",
|
|
79
|
+
proof: "proof",
|
|
80
|
+
delivered: "delivered",
|
|
81
|
+
why: "why",
|
|
82
|
+
do: "do",
|
|
83
|
+
judge: "judge",
|
|
84
|
+
missing: "missing",
|
|
85
|
+
error: "error",
|
|
86
|
+
checksGreen: "checks green",
|
|
87
|
+
checksOf: "checks passed",
|
|
88
|
+
judgePass: "judge pass",
|
|
89
|
+
judgePasses: "judge passes",
|
|
90
|
+
judgeRejected: "judge rejected",
|
|
91
|
+
noJudge: "no judge",
|
|
92
|
+
revisions: "revisions",
|
|
93
|
+
nodes: "nodes",
|
|
94
|
+
next: "next",
|
|
95
|
+
waitingOnYou: "waiting on you",
|
|
96
|
+
phases: "phases",
|
|
97
|
+
cache: "cache",
|
|
98
|
+
in: "in",
|
|
99
|
+
out: "out",
|
|
100
|
+
nextCommand: "next",
|
|
101
|
+
available: "available",
|
|
102
|
+
runUpdate: "run",
|
|
103
|
+
noSummary: "(no summary recorded)",
|
|
104
|
+
noObjective: "(no objective recorded)",
|
|
105
|
+
more: "more",
|
|
106
|
+
complete: "complete",
|
|
107
|
+
},
|
|
108
|
+
pt: {
|
|
109
|
+
doneIn: "concluído em",
|
|
110
|
+
running: "rodando",
|
|
111
|
+
pending: "pendente",
|
|
112
|
+
state: "estado",
|
|
113
|
+
nothingDelivered: "nada entregue",
|
|
114
|
+
"no-op": "sem mudança após",
|
|
115
|
+
failedAfter: "falhou após",
|
|
116
|
+
blockedAfter: "bloqueado após",
|
|
117
|
+
exhaustedAfter: "esgotado após",
|
|
118
|
+
stalledAfter: "travou após",
|
|
119
|
+
canceledAfter: "cancelado após",
|
|
120
|
+
needsYou: "precisa de você",
|
|
121
|
+
attempt: "tentativa",
|
|
122
|
+
run: "run",
|
|
123
|
+
done: "concluídos",
|
|
124
|
+
asked: "pedido",
|
|
125
|
+
did: "feito",
|
|
126
|
+
proof: "prova",
|
|
127
|
+
delivered: "entregue",
|
|
128
|
+
why: "motivo",
|
|
129
|
+
do: "ação",
|
|
130
|
+
judge: "juiz",
|
|
131
|
+
missing: "faltou",
|
|
132
|
+
error: "erro",
|
|
133
|
+
checksGreen: "checagens verdes",
|
|
134
|
+
checksOf: "checagens passaram",
|
|
135
|
+
judgePass: "juiz aprovou",
|
|
136
|
+
judgePasses: "aprovações do juiz",
|
|
137
|
+
judgeRejected: "juiz rejeitou",
|
|
138
|
+
noJudge: "sem juiz",
|
|
139
|
+
revisions: "revisões",
|
|
140
|
+
nodes: "nós",
|
|
141
|
+
next: "próximo",
|
|
142
|
+
waitingOnYou: "esperando você",
|
|
143
|
+
phases: "fases",
|
|
144
|
+
cache: "cache",
|
|
145
|
+
in: "entrada",
|
|
146
|
+
out: "saída",
|
|
147
|
+
nextCommand: "próximo passo",
|
|
148
|
+
available: "disponível",
|
|
149
|
+
runUpdate: "rode",
|
|
150
|
+
noSummary: "(sem resumo registrado)",
|
|
151
|
+
noObjective: "(sem objetivo registrado)",
|
|
152
|
+
more: "mais",
|
|
153
|
+
complete: "completa",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {Language} language
|
|
159
|
+
* @returns {(key: string) => string}
|
|
160
|
+
*/
|
|
161
|
+
export function labelsFor(language) {
|
|
162
|
+
const table = LABELS[language];
|
|
163
|
+
return (key) => table[key] ?? LABELS.en[key] ?? key;
|
|
164
|
+
}
|