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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packet repetition between a node's successive attempts — the R16
|
|
3
|
+
* measurement the deduplication decision waits for, and nothing more: this
|
|
4
|
+
* module counts bytes; it never deduplicates or reshapes a payload.
|
|
5
|
+
*
|
|
6
|
+
* It is a module of its own because render.mjs sits at the repository's
|
|
7
|
+
* 800-line ceiling (measured 2026-09-21: render.mjs 799, progress.mjs 724,
|
|
8
|
+
* final.mjs 202): the renderers gain a single line each, the computation
|
|
9
|
+
* lives here.
|
|
10
|
+
*
|
|
11
|
+
* A node's packet, per attempt, is the prompt file its worker invocation was
|
|
12
|
+
* dispatched with (`invocation.promptPath`) — the only per-attempt packet
|
|
13
|
+
* bytes a run persists. Paths resolve against the run directory; an absolute
|
|
14
|
+
* path is taken as written. "Repeated" between two successive attempts is
|
|
15
|
+
* their common byte prefix: the share a retry re-sends identically, the
|
|
16
|
+
* number a deduplication or prefix-cache decision would act on. A packet
|
|
17
|
+
* file that cannot be read leaves its attempt absent from the measurement —
|
|
18
|
+
* absent is never zero — and `packetsRead` beside `attempts` is how a reader
|
|
19
|
+
* sees the gap.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
import { compactTokens } from "../util.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{attempts: number, packetsRead: number, packetBytes: number|null, repeatedBytes: number|null}} PacketRepetition
|
|
27
|
+
* `packetBytes` sums the packets that were readable; `repeatedBytes` sums the
|
|
28
|
+
* common prefix of each *adjacent* worker-invocation pair whose both packets
|
|
29
|
+
* were readable — `null` when no pair was measurable. When `packetsRead` is
|
|
30
|
+
* smaller than `attempts`, the totals cover only the packets still on disk.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per node, from the invocation ledger alone, in contract order.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} runDir
|
|
37
|
+
* @param {{id: string, invocations?: unknown}[]} nodes
|
|
38
|
+
* @returns {Map<string, PacketRepetition>}
|
|
39
|
+
*/
|
|
40
|
+
export function packetRepetitionByNode(runDir, nodes) {
|
|
41
|
+
return new Map(nodes.map((node) => [node.id, packetRepetition(runDir, node.invocations)]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} runDir
|
|
46
|
+
* @param {unknown} invocations
|
|
47
|
+
* @returns {PacketRepetition}
|
|
48
|
+
*/
|
|
49
|
+
function packetRepetition(runDir, invocations) {
|
|
50
|
+
const ledger = Array.isArray(invocations) ? /** @type {Record<string, unknown>[]} */ (invocations) : [];
|
|
51
|
+
const workerPackets = ledger
|
|
52
|
+
.filter((invocation) => /** @type {{role?: unknown}} */ (invocation)?.role === "worker")
|
|
53
|
+
.map((invocation) => (typeof /** @type {{promptPath?: unknown}} */ (invocation).promptPath === "string"
|
|
54
|
+
? readPacket(runDir, /** @type {string} */ (/** @type {{promptPath?: unknown}} */ (invocation).promptPath))
|
|
55
|
+
: null));
|
|
56
|
+
let repeatedBytes = /** @type {number|null} */ (null);
|
|
57
|
+
for (let index = 1; index < workerPackets.length; index += 1) {
|
|
58
|
+
const previous = workerPackets[index - 1];
|
|
59
|
+
const current = workerPackets[index];
|
|
60
|
+
// A gap in the ledger's packets breaks the pair rather than pairing
|
|
61
|
+
// across it: attempts i-1 and i+1 are not successive.
|
|
62
|
+
if (previous === null || current === null) continue;
|
|
63
|
+
repeatedBytes = (repeatedBytes ?? 0) + sharedPrefixBytes(previous, current);
|
|
64
|
+
}
|
|
65
|
+
const readable = workerPackets.filter((packetBytes) => packetBytes !== null);
|
|
66
|
+
return {
|
|
67
|
+
attempts: workerPackets.length,
|
|
68
|
+
packetsRead: readable.length,
|
|
69
|
+
packetBytes: readable.length ? readable.reduce((total, packet) => total + /** @type {Buffer} */ (packet).length, 0) : null,
|
|
70
|
+
repeatedBytes,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} runDir
|
|
76
|
+
* @param {string} promptPath
|
|
77
|
+
* @returns {Buffer|null}
|
|
78
|
+
*/
|
|
79
|
+
function readPacket(runDir, promptPath) {
|
|
80
|
+
try {
|
|
81
|
+
return readFileSync(resolve(runDir, promptPath));
|
|
82
|
+
} catch {
|
|
83
|
+
// A prompt file the run no longer carries (pruned, or a fixture's
|
|
84
|
+
// placeholder path) leaves that attempt absent from the measurement
|
|
85
|
+
// rather than counted as zero bytes; `packetsRead` shows the gap.
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {Buffer} left @param {Buffer} right @returns {number} */
|
|
91
|
+
function sharedPrefixBytes(left, right) {
|
|
92
|
+
const max = Math.min(left.length, right.length);
|
|
93
|
+
let index = 0;
|
|
94
|
+
while (index < max && left[index] === right[index]) index += 1;
|
|
95
|
+
return index;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The report's one-line exposure, appended to a totals line: per node with
|
|
100
|
+
* two or more attempts on record, the bytes its successive attempts re-sent
|
|
101
|
+
* identically of the packet bytes those attempts carried. A node with a
|
|
102
|
+
* single attempt has no successive pair and is rightly absent; a node whose
|
|
103
|
+
* packets cannot be read says `unmeasured` rather than a zero; a node whose
|
|
104
|
+
* measurement covers only some of its packets says `partial`.
|
|
105
|
+
*
|
|
106
|
+
* @param {Map<string, PacketRepetition>} repetition
|
|
107
|
+
* @returns {string} "" when no node has a successive pair to expose
|
|
108
|
+
*/
|
|
109
|
+
export function packetRepetitionNote(repetition) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
for (const [id, measured] of repetition) {
|
|
112
|
+
if (measured.attempts < 2) continue;
|
|
113
|
+
if (measured.repeatedBytes === null || measured.packetBytes === null) {
|
|
114
|
+
parts.push(`${id} unmeasured`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const partial = measured.packetsRead < measured.attempts ? " (partial)" : "";
|
|
118
|
+
parts.push(`${id} ${compactTokens(measured.repeatedBytes)} of ${compactTokens(measured.packetBytes)}${partial}`);
|
|
119
|
+
}
|
|
120
|
+
return parts.length ? ` · packet repeat across attempts: ${parts.join(" · ")}` : "";
|
|
121
|
+
}
|
package/src/report/progress.mjs
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* state plus one terminal event: campaign, phase and node with a glyph for
|
|
7
|
-
* the node's outcome; percent and count done and left; this node's own span;
|
|
8
|
-
* the phase's estimated remaining time, from settled spans alone; the next
|
|
9
|
-
* node in the phase, or that the phase is complete; what the node delivered,
|
|
10
|
-
* in the worker's own words; and cost by role plus the campaign's cumulative
|
|
11
|
-
* cost.
|
|
2
|
+
* The campaign roll-up, and the numbers it shares with the notification
|
|
3
|
+
* message (`message.mjs`): the roll-up object itself, the remaining-time
|
|
4
|
+
* estimate and a node's span are exported from here and imported there, so
|
|
5
|
+
* the page and the message can never disagree about progress, cost or time.
|
|
12
6
|
*
|
|
13
7
|
* `renderCampaignProgress` -- the campaign-wide roll-up, answering the
|
|
14
8
|
* owner's four questions from persisted state alone: what the campaign
|
|
@@ -36,9 +30,9 @@
|
|
|
36
30
|
* per-role arithmetic the status payload uses for this run alone. Nothing
|
|
37
31
|
* here recomputes what one of those already answers.
|
|
38
32
|
*/
|
|
39
|
-
import {
|
|
33
|
+
import { formatDuration, readRunUsage, renderStatusJson, roleUsage } from "./render.mjs";
|
|
40
34
|
import { nodeSnapshotPath, readNodeSnapshot } from "../run/node-store.mjs";
|
|
41
|
-
import {
|
|
35
|
+
import { finite } from "../util.mjs";
|
|
42
36
|
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
43
37
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
44
38
|
import { readCampaign } from "../campaign/record.mjs";
|
|
@@ -47,66 +41,9 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
47
41
|
import { dirname, join, resolve } from "node:path";
|
|
48
42
|
import { runDirectory } from "../run/paths.mjs";
|
|
49
43
|
|
|
50
|
-
/** @typedef {import("./render.mjs").StatusPayloadNode} StatusPayloadNode */
|
|
51
|
-
/** @typedef {import("../notify/index.mjs").NotifyEvent} NotifyEvent */
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The message's byte ceiling. A three-node run, both roles priced and a
|
|
55
|
-
* two-sentence worker summary, measures ~430 bytes; this leaves headroom for
|
|
56
|
-
* a long campaign/phase/node id triple while still bounding an adversarial
|
|
57
|
-
* worker summary (the protocol's own 4 KiB ceiling), which is the only
|
|
58
|
-
* unbounded block and the one truncated first.
|
|
59
|
-
*/
|
|
60
|
-
export const PROGRESS_MESSAGE_MAX_BYTES = 2048;
|
|
61
44
|
|
|
62
45
|
const NO_SUMMARY = "(no summary recorded)";
|
|
63
46
|
|
|
64
|
-
/**
|
|
65
|
-
* @param {string} runDir
|
|
66
|
-
* @param {NotifyEvent} event
|
|
67
|
-
* @returns {string}
|
|
68
|
-
*/
|
|
69
|
-
export function renderRunProgress(runDir, event) {
|
|
70
|
-
const payload = /** @type {{campaignId: string, usage: {costUsd: number|null}, roles: {worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}, nodes: StatusPayloadNode[]}} */
|
|
71
|
-
(JSON.parse(renderStatusJson(runDir)));
|
|
72
|
-
const subject = subjectNode(payload.nodes, event.nodeId ?? null);
|
|
73
|
-
const raw = readNodeSnapshot(runDir, subject.id);
|
|
74
|
-
const phaseNodes = payload.nodes.filter((node) => node.phase === subject.phase);
|
|
75
|
-
const total = payload.nodes.length;
|
|
76
|
-
const done = payload.nodes.filter((node) => SETTLED.has(node.status)).length;
|
|
77
|
-
const donePct = total ? Math.round((done / total) * 100) : 0;
|
|
78
|
-
|
|
79
|
-
const lines = [
|
|
80
|
-
`${MARK[subject.status] ?? "[?]"} campaign ${payload.campaignId} · phase ${subject.phase ?? "-"} · node ${subject.id}`,
|
|
81
|
-
`${done}/${total} nodes done · ${donePct}% done · ${100 - donePct}% left`,
|
|
82
|
-
`this node: ${spanOf(subject) ?? "-"}`,
|
|
83
|
-
phaseEstimateLine(phaseNodes),
|
|
84
|
-
nextNodeLine(phaseNodes),
|
|
85
|
-
];
|
|
86
|
-
if (event.type === "attention") lines.push(attentionLine(runDir, subject, raw));
|
|
87
|
-
const valueLineIndex = lines.push(valueLine(raw)) - 1;
|
|
88
|
-
lines.push(costLine(runDir, payload));
|
|
89
|
-
|
|
90
|
-
return boundToCeiling(lines, valueLineIndex);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The event's own node when it names one and the run still has it, else the
|
|
95
|
-
* most recently settled node (a `run.terminal` event names the run, not a
|
|
96
|
-
* node), else the run's first node.
|
|
97
|
-
*
|
|
98
|
-
* @param {StatusPayloadNode[]} nodes
|
|
99
|
-
* @param {string|null} nodeId
|
|
100
|
-
* @returns {StatusPayloadNode}
|
|
101
|
-
*/
|
|
102
|
-
function subjectNode(nodes, nodeId) {
|
|
103
|
-
const named = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
|
104
|
-
if (named) return named;
|
|
105
|
-
const settled = nodes.filter((node) => SETTLED.has(node.status) && node.updatedAt);
|
|
106
|
-
const latest = settled.sort((left, right) => String(left.updatedAt).localeCompare(String(right.updatedAt))).at(-1);
|
|
107
|
-
return latest ?? nodes[0];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
47
|
/** @param {{startedAt: string|null, updatedAt: string|null}} node @returns {number|null} */
|
|
111
48
|
function spanMs(node) {
|
|
112
49
|
if (!node.startedAt || !node.updatedAt) return null;
|
|
@@ -122,73 +59,43 @@ function spanOf(node) {
|
|
|
122
59
|
}
|
|
123
60
|
|
|
124
61
|
/**
|
|
125
|
-
* The remaining-time estimate,
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* `SETTLED`, so it never drags the mean down and the estimate forward --
|
|
129
|
-
* times how many nodes have not settled. `"complete"` when nothing is left;
|
|
130
|
-
* `null` when nothing has settled to base the estimate on -- absent, never
|
|
131
|
-
* zero, the same refusal this page applies to requirement coverage.
|
|
62
|
+
* The remaining-time estimate as a number, for a renderer that formats it its
|
|
63
|
+
* own way: `0` when nothing is left, `null` when nothing has settled to base
|
|
64
|
+
* it on, else the mean settled span times the unsettled count.
|
|
132
65
|
*
|
|
133
|
-
* @param {{status: string}[]} nodes
|
|
134
|
-
* @param {{status: string, startedAt: string|null, updatedAt: string|null}[]} spans
|
|
135
|
-
* @returns {
|
|
66
|
+
* @param {{status: string}[]} nodes
|
|
67
|
+
* @param {{status: string, startedAt: string|null, updatedAt: string|null}[]} spans
|
|
68
|
+
* @returns {number|null}
|
|
136
69
|
*/
|
|
137
|
-
function
|
|
70
|
+
export function remainingEstimateMs(nodes, spans) {
|
|
138
71
|
const unsettled = nodes.filter((node) => !SETTLED.has(node.status)).length;
|
|
139
|
-
if (!unsettled) return
|
|
72
|
+
if (!unsettled) return 0;
|
|
140
73
|
const settledSpans = spans.filter((node) => SETTLED.has(node.status)).map(spanMs).filter((ms) => ms !== null);
|
|
141
74
|
if (!settledSpans.length) return null;
|
|
142
75
|
const meanMs = settledSpans.reduce((total, ms) => total + /** @type {number} */ (ms), 0) / settledSpans.length;
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** @param {StatusPayloadNode[]} phaseNodes @returns {string} */
|
|
147
|
-
function phaseEstimateLine(phaseNodes) {
|
|
148
|
-
return `phase estimate: ${remainingEstimate(phaseNodes, phaseNodes) ?? "unknown (no settled node yet)"}`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** @param {StatusPayloadNode[]} phaseNodes @returns {string} */
|
|
152
|
-
function nextNodeLine(phaseNodes) {
|
|
153
|
-
const next = phaseNodes.find((node) => !SETTLED.has(node.status));
|
|
154
|
-
return next ? `next: ${next.id}` : "next: phase complete";
|
|
76
|
+
return meanMs * unsettled;
|
|
155
77
|
}
|
|
156
78
|
|
|
157
79
|
/**
|
|
158
|
-
* The
|
|
159
|
-
* `
|
|
160
|
-
*
|
|
80
|
+
* The remaining-time estimate, shared by the notification message
|
|
81
|
+
* (`message.mjs`) and the campaign roll-up so neither invents a second one:
|
|
82
|
+
* the mean span of the settled nodes alone -- a running node's partial
|
|
83
|
+
* elapsed is never in `SETTLED`, so it never drags the mean down and the
|
|
84
|
+
* estimate forward -- times how many nodes have not settled. `"complete"`
|
|
85
|
+
* when nothing is left; `null` when nothing has settled to base the estimate
|
|
86
|
+
* on -- absent, never zero, the same refusal this page applies to requirement
|
|
87
|
+
* coverage.
|
|
161
88
|
*
|
|
162
|
-
* @param {
|
|
163
|
-
* @
|
|
164
|
-
|
|
165
|
-
function valueLine(raw) {
|
|
166
|
-
const result = /** @type {{summary?: unknown}|null} */ (raw.result && typeof raw.result === "object" ? raw.result : null);
|
|
167
|
-
const summary = typeof result?.summary === "string" && result.summary.trim() ? result.summary.trim() : null;
|
|
168
|
-
return `worker says: ${summary ?? NO_SUMMARY}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* The attention variant names what must be decided and the exact command
|
|
173
|
-
* that unblocks it, the way `src/report/next.mjs` does for its own items.
|
|
174
|
-
*
|
|
175
|
-
* @param {string} runDir
|
|
176
|
-
* @param {StatusPayloadNode} subject
|
|
177
|
-
* @param {Record<string, unknown>} raw
|
|
178
|
-
* @returns {string}
|
|
89
|
+
* @param {{status: string}[]} nodes every node the estimate covers
|
|
90
|
+
* @param {{status: string, startedAt: string|null, updatedAt: string|null}[]} spans the nodes that carry the timestamps to average
|
|
91
|
+
* @returns {string|null}
|
|
179
92
|
*/
|
|
180
|
-
function
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
const gate = /** @type {{findings?: unknown}|null} */ (raw.gate && typeof raw.gate === "object" ? raw.gate : null);
|
|
188
|
-
if (subject.status === "exhausted" && Array.isArray(gate?.findings) && gate.findings.length) {
|
|
189
|
-
return `decide: review node ${subject.id}'s gate findings → findings ${runDir}`;
|
|
190
|
-
}
|
|
191
|
-
return `decide: resolve node ${subject.id} (${subject.status}) → status ${runDir}`;
|
|
93
|
+
export function remainingEstimate(nodes, spans) {
|
|
94
|
+
const ms = remainingEstimateMs(nodes, spans);
|
|
95
|
+
if (ms === 0) return "complete";
|
|
96
|
+
if (ms === null) return null;
|
|
97
|
+
const settled = spans.filter((node) => SETTLED.has(node.status) && spanMs(node) !== null).length;
|
|
98
|
+
return `~${formatDuration(ms)} remaining (from ${settled} settled node${settled === 1 ? "" : "s"})`;
|
|
192
99
|
}
|
|
193
100
|
|
|
194
101
|
/**
|
|
@@ -220,33 +127,6 @@ function campaignCumulativeCostUsd(runsDir, campaignId) {
|
|
|
220
127
|
return total;
|
|
221
128
|
}
|
|
222
129
|
|
|
223
|
-
/**
|
|
224
|
-
* @param {string} runDir
|
|
225
|
-
* @param {{campaignId: string, roles: {worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}}} payload
|
|
226
|
-
* @returns {string}
|
|
227
|
-
*/
|
|
228
|
-
function costLine(runDir, payload) {
|
|
229
|
-
const cumulative = campaignCumulativeCostUsd(dirname(runDir), payload.campaignId);
|
|
230
|
-
return `cost — worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · campaign total ${compactCost(cumulative)}`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* The value block is the only unbounded part of the message, so it is the
|
|
235
|
-
* only one truncated when the whole message would exceed the ceiling.
|
|
236
|
-
*
|
|
237
|
-
* @param {string[]} lines
|
|
238
|
-
* @param {number} valueLineIndex
|
|
239
|
-
* @returns {string}
|
|
240
|
-
*/
|
|
241
|
-
function boundToCeiling(lines, valueLineIndex) {
|
|
242
|
-
const overflow = Buffer.byteLength(lines.join("\n"), "utf8") - PROGRESS_MESSAGE_MAX_BYTES;
|
|
243
|
-
if (overflow <= 0) return lines.join("\n");
|
|
244
|
-
const valueLineBytes = Buffer.byteLength(lines[valueLineIndex], "utf8");
|
|
245
|
-
const trimmed = [...lines];
|
|
246
|
-
trimmed[valueLineIndex] = boundedUtf8(lines[valueLineIndex], Math.max(0, valueLineBytes - overflow));
|
|
247
|
-
return trimmed.join("\n");
|
|
248
|
-
}
|
|
249
|
-
|
|
250
130
|
/**
|
|
251
131
|
* @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
|
|
252
132
|
* `status` carries every `NodeStatus` plus two the manifest alone can
|
|
@@ -292,7 +172,7 @@ export function renderCampaignProgress(runsDir, campaignId, now = Date.now()) {
|
|
|
292
172
|
* @param {number} now
|
|
293
173
|
* @returns {Record<string, unknown>}
|
|
294
174
|
*/
|
|
295
|
-
function buildCampaignProgress(runsDir, campaignId, now) {
|
|
175
|
+
export function buildCampaignProgress(runsDir, campaignId, now) {
|
|
296
176
|
const campaignPath = campaignDir(runsDir, campaignId);
|
|
297
177
|
const campaign = readCampaign(campaignPath);
|
|
298
178
|
// The manifest is a queue of what is declared to run next, and the
|
package/src/report/render.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { compactCost, compactTokens, finite, truncateChars } from "../util.mjs";
|
|
|
10
10
|
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
11
11
|
import { readHeartbeat } from "../engine/supervise.mjs";
|
|
12
12
|
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
13
|
+
import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
|
|
13
14
|
|
|
14
15
|
/** Advisory ceiling for status.json (TECH-SPEC lean, rule 5); never enforced destructively. */
|
|
15
16
|
const STATUS_JSON_MAX_BYTES = 200 * 1024;
|
|
@@ -396,17 +397,16 @@ export function renderReport(runDir) {
|
|
|
396
397
|
for (const [index, node] of nodes.entries()) {
|
|
397
398
|
const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
|
|
398
399
|
for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(totals).filter((key) => key !== "costUsd"))) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
|
|
399
|
-
const cost = costs[index];
|
|
400
400
|
const runtime = workerRuntimeLabel(node) ?? "-";
|
|
401
401
|
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
402
402
|
const note = scopeFindingsNote(node.scopeFindings)
|
|
403
403
|
? nodeNote(node)
|
|
404
404
|
: `phase ${planNode?.phase ?? "-"} · ${continuationMode(node)} · ${nodeNote(node)}`;
|
|
405
|
-
lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, node.attempt ?? 0, node.revisions ?? 0, runtime, compactTokens(usage.inputTokens), compactTokens(usage.outputTokens), compactTokens(usage.cacheReadInputTokens), formatCost(
|
|
405
|
+
lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, node.attempt ?? 0, node.revisions ?? 0, runtime, compactTokens(usage.inputTokens), compactTokens(usage.outputTokens), compactTokens(usage.cacheReadInputTokens), formatCost(costs[index]), note]));
|
|
406
406
|
}
|
|
407
407
|
totals.costUsd = aggregateCost.costUsd;
|
|
408
408
|
const roles = roleUsage(nodes);
|
|
409
|
-
lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${formatRole(roles.worker)} · judge ${formatRole(roles.judge)} · cost ${formatCost(aggregateCost)}`);
|
|
409
|
+
lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${formatRole(roles.worker)} · judge ${formatRole(roles.judge)} · cost ${formatCost(aggregateCost)}${packetRepetitionNote(packetRepetitionByNode(runDir, nodes))}`);
|
|
410
410
|
return `${lines.join("\n")}\n`;
|
|
411
411
|
}
|
|
412
412
|
|
package/src/seat/tmux.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* command working on a host without tmux, where only reattaching is lost.
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
|
|
11
12
|
import { errorCode, exitStatus } from "../util.mjs";
|
|
12
13
|
|
|
13
14
|
/** The single seat session every campaign window lives in. */
|
|
@@ -16,6 +17,14 @@ export const SEAT_SESSION = "faberun-seat";
|
|
|
16
17
|
/** Window option recording which harness launched the window. */
|
|
17
18
|
const HARNESS_OPTION = "@faberun-harness";
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The one variable a seat window sets for the harness it launches: every
|
|
22
|
+
* controller started from that harness inherits it and wakes the session on
|
|
23
|
+
* a terminal node (`notify/session.mjs`). The seat is the opt-in, so a test
|
|
24
|
+
* suite or a plain shell never wakes anything by accident.
|
|
25
|
+
*/
|
|
26
|
+
const SESSION_WAKE_ENV = `${NOTIFY_SESSION_ENV}=auto`;
|
|
27
|
+
|
|
19
28
|
/** `list-windows` format: name, index, harness option, pane command, tab-separated. */
|
|
20
29
|
const WINDOW_FORMAT = "#{window_name}\t#{window_index}\t#{@faberun-harness}\t#{pane_current_command}";
|
|
21
30
|
|
|
@@ -95,8 +104,8 @@ export function createSeatWindow(options) {
|
|
|
95
104
|
}
|
|
96
105
|
const command = shellCommand(options.argv);
|
|
97
106
|
const args = existing.exists
|
|
98
|
-
? ["new-window", "-t", options.session, "-n", options.window, "-c", options.cwd, command]
|
|
99
|
-
: ["new-session", "-d", "-s", options.session, "-n", options.window, "-c", options.cwd, command];
|
|
107
|
+
? ["new-window", "-t", options.session, "-n", options.window, "-c", options.cwd, "-e", SESSION_WAKE_ENV, command]
|
|
108
|
+
: ["new-session", "-d", "-s", options.session, "-n", options.window, "-c", options.cwd, "-e", SESSION_WAKE_ENV, command];
|
|
100
109
|
const result = runTmux(args);
|
|
101
110
|
if (!result.ok) {
|
|
102
111
|
return { available: result.available, ok: false, created: false, session: options.session, window: options.window, command, reason: result.reason, stderr: result.stderr };
|
|
@@ -123,7 +132,7 @@ function shellCommand(argv) {
|
|
|
123
132
|
*/
|
|
124
133
|
export function respawnSeatWindow(options) {
|
|
125
134
|
const command = shellCommand(options.argv);
|
|
126
|
-
const result = runTmux(["respawn-window", "-k", "-t", `${options.session}:${options.window}`, "-c", options.cwd, command]);
|
|
135
|
+
const result = runTmux(["respawn-window", "-k", "-t", `${options.session}:${options.window}`, "-c", options.cwd, "-e", SESSION_WAKE_ENV, command]);
|
|
127
136
|
if (!result.ok) {
|
|
128
137
|
return { available: result.available, ok: false, respawned: false, session: options.session, window: options.window, command, reason: result.reason, stderr: result.stderr };
|
|
129
138
|
}
|