omp-conductor 0.19.7 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +570 -1
package/src/stats.ts
CHANGED
|
@@ -17,10 +17,32 @@
|
|
|
17
17
|
* the doctor `spend-telemetry` finding): a run whose `spendUsd` reads 0.00
|
|
18
18
|
* means harness telemetry was absent, not that the run was free. Such runs are
|
|
19
19
|
* counted separately as **unmetered** and never averaged into a per-issue cost.
|
|
20
|
+
*
|
|
21
|
+
* ## Attribution (Phase 4)
|
|
22
|
+
*
|
|
23
|
+
* Two additions, both about *where the money went* rather than what it bought:
|
|
24
|
+
*
|
|
25
|
+
* - **Per model.** Worker runs grouped by `resolvedModel` — the model the
|
|
26
|
+
* harness actually ran and therefore the one that was billed. Never grouped
|
|
27
|
+
* by the requested model: a requested model is not evidence of a resolved
|
|
28
|
+
* one, and a row that observed none groups under {@link UNRESOLVED_MODEL}
|
|
29
|
+
* rather than being guessed into a bucket.
|
|
30
|
+
* - **Daemon-owned sessions.** To-spec grooming and review adjudication run as
|
|
31
|
+
* the daemon's own sessions, not as worker runs, so their cost was logged and
|
|
32
|
+
* then lost. They arrive here as `sessionSpendSince` rows and are summed *per
|
|
33
|
+
* role*, deliberately outside the issue-journey aggregation: a role row in
|
|
34
|
+
* `runs` would corrupt the merged/settled counts, which is why the store
|
|
35
|
+
* keeps them in their own table.
|
|
36
|
+
*
|
|
37
|
+
* The orchestrator's own tick turns are absent from both, and that absence is
|
|
38
|
+
* reported rather than hidden ({@link StatsReport.unmeteredRoles}): the tick
|
|
39
|
+
* extension observes no usage events at all, so there is no number — and a
|
|
40
|
+
* `$0.00` for it would read as free.
|
|
20
41
|
*/
|
|
21
42
|
|
|
22
43
|
import { utcDay } from "./store.ts";
|
|
23
|
-
import
|
|
44
|
+
import { SESSION_SPEND_ROLES } from "./types.ts";
|
|
45
|
+
import type { RunRecord, SessionSpendRole, SessionSpendRow } from "./types.ts";
|
|
24
46
|
|
|
25
47
|
/** The bounded window a report answers over. Day keys are UTC (`YYYY-MM-DD`). */
|
|
26
48
|
export interface StatsWindow {
|
|
@@ -57,6 +79,51 @@ export interface RepoStats {
|
|
|
57
79
|
failureClasses: Record<string, number>;
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Worker runs attributed to one billed model (Phase 4).
|
|
84
|
+
*
|
|
85
|
+
* Counted over every run in the outcomes this report counts — the failures
|
|
86
|
+
* included, because money spent losing is precisely what a per-model
|
|
87
|
+
* comparison exists to expose: a model that burns a budget and never merges
|
|
88
|
+
* must not read as cheap.
|
|
89
|
+
*
|
|
90
|
+
* So this is deliberately a *wider* figure than {@link RepoStats.spendUsd},
|
|
91
|
+
* which sums merged chains only. The two answer different questions ("what did
|
|
92
|
+
* delivered work cost" versus "what did this model cost us"), and they are not
|
|
93
|
+
* meant to add up to each other.
|
|
94
|
+
*/
|
|
95
|
+
export interface ModelStats {
|
|
96
|
+
/** `resolvedModel`, or {@link UNRESOLVED_MODEL} when the run observed none. */
|
|
97
|
+
model: string;
|
|
98
|
+
/** Runs in the window on this model, across every issue. */
|
|
99
|
+
runs: number;
|
|
100
|
+
/** Runs whose state is `merged` — the attempt that actually delivered. */
|
|
101
|
+
merges: number;
|
|
102
|
+
/** Metered (spendUsd > 0) spend on this model, rounded to cents. */
|
|
103
|
+
spendUsd: number;
|
|
104
|
+
/** `spendUsd / merges`; `null` with no merge to divide by — never a zero,
|
|
105
|
+
* and never a division by zero. */
|
|
106
|
+
spendPerMerge: number | null;
|
|
107
|
+
/** Runs on this model whose spend read $0.00: telemetry absent, not free. */
|
|
108
|
+
unmeteredRuns: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One daemon-owned session role's accounting (Phase 4). */
|
|
112
|
+
export interface SessionRoleStats {
|
|
113
|
+
role: SessionSpendRole;
|
|
114
|
+
sessions: number;
|
|
115
|
+
turns: number;
|
|
116
|
+
/** Summed cost of the sessions that reported one, rounded to cents. */
|
|
117
|
+
spendUsd: number;
|
|
118
|
+
/**
|
|
119
|
+
* Sessions whose provider reported no cost at all. Distinct from a reported
|
|
120
|
+
* `0` by contract (`SessionSpendRow.spendUsd` is optional for exactly this
|
|
121
|
+
* reason), so this count is the honest "cost unknown" — never folded into
|
|
122
|
+
* `spendUsd` as a zero.
|
|
123
|
+
*/
|
|
124
|
+
unmeteredSessions: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
60
127
|
/** The stable report shape `--json` prints; the human renderer reads it too. */
|
|
61
128
|
export interface StatsReport {
|
|
62
129
|
project: string;
|
|
@@ -72,6 +139,27 @@ export interface StatsReport {
|
|
|
72
139
|
total: RepoStats;
|
|
73
140
|
/** Per-repo aggregates of the same shape, busiest (by merges) first. */
|
|
74
141
|
repos: RepoStats[];
|
|
142
|
+
/** Worker runs by billed model, busiest (by runs) first (Phase 4). */
|
|
143
|
+
models: ModelStats[];
|
|
144
|
+
/** Daemon-owned session spend by role, in `SESSION_SPEND_ROLES` order. */
|
|
145
|
+
sessions: SessionRoleStats[];
|
|
146
|
+
/**
|
|
147
|
+
* Metered spend on to-spec grooming sessions — the plan's `groomSpendUsd`,
|
|
148
|
+
* named at the top level because "what did keeping the queue full cost" is a
|
|
149
|
+
* question asked on its own. Same number as the `groom` row in
|
|
150
|
+
* {@link StatsReport.sessions}; `groomUnmeteredSessions` is the part of it
|
|
151
|
+
* whose cost is unknown rather than zero.
|
|
152
|
+
*/
|
|
153
|
+
groomSpendUsd: number;
|
|
154
|
+
groomUnmeteredSessions: number;
|
|
155
|
+
/**
|
|
156
|
+
* Roles whose spend cannot be observed at all, so no row exists to sum and
|
|
157
|
+
* no total here includes them. Present in the shape so a consumer adding
|
|
158
|
+
* `total.spendUsd` to `sessions` knows what is still missing instead of
|
|
159
|
+
* reading the sum as the fleet's whole bill. Today: `orchestrator` — the
|
|
160
|
+
* tick extension observes no usage events.
|
|
161
|
+
*/
|
|
162
|
+
unmeteredRoles: string[];
|
|
75
163
|
}
|
|
76
164
|
|
|
77
165
|
/** Terminal non-merged run states — everything the failure histogram counts. */
|
|
@@ -86,6 +174,16 @@ const FAILED_STATES: ReadonlySet<RunRecord["state"]> = new Set([
|
|
|
86
174
|
/** Runs with no classified failure fall into this explicit bucket. */
|
|
87
175
|
export const UNCLASSIFIED = "unclassified";
|
|
88
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Runs that never observed a resolved model group here (Phase 4).
|
|
179
|
+
*
|
|
180
|
+
* Parenthesised so it cannot collide with a real model id, and explicit rather
|
|
181
|
+
* than omitted: a run predating the column, or one whose harness never reported
|
|
182
|
+
* the model it ran, still cost money, and dropping it would make the per-model
|
|
183
|
+
* spend quietly fail to add up to the total.
|
|
184
|
+
*/
|
|
185
|
+
export const UNRESOLVED_MODEL = "(unresolved)";
|
|
186
|
+
|
|
89
187
|
/** The timestamp a run is placed on the window timeline. Mirrors the store's
|
|
90
188
|
* `COALESCE(endedAt, startedAt)` convention everywhere else in this package. */
|
|
91
189
|
const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt;
|
|
@@ -216,11 +314,19 @@ export function parseStatsWindow(raw: string, now: number): StatsWindow | undefi
|
|
|
216
314
|
return undefined;
|
|
217
315
|
}
|
|
218
316
|
|
|
317
|
+
/**
|
|
318
|
+
* `sessions` is `store.sessionSpendSince(project, window.sinceEpochMs)`. It is
|
|
319
|
+
* optional because a caller with nothing to say about daemon-owned sessions —
|
|
320
|
+
* and every pre-Phase-4 caller — must still get a report; an empty list means
|
|
321
|
+
* "no grooming or adjudication in this window", which the renderer states as
|
|
322
|
+
* that rather than as a $0.00 bill.
|
|
323
|
+
*/
|
|
219
324
|
export function computeStats(args: {
|
|
220
325
|
project: string;
|
|
221
326
|
window: StatsWindow;
|
|
222
327
|
ghCalls: number;
|
|
223
328
|
runs: readonly RunRecord[];
|
|
329
|
+
sessions?: readonly SessionSpendRow[];
|
|
224
330
|
}): StatsReport {
|
|
225
331
|
const chains = new Map<number, RunRecord[]>();
|
|
226
332
|
for (const run of args.runs) {
|
|
@@ -234,6 +340,7 @@ export function computeStats(args: {
|
|
|
234
340
|
const failureRuns: { repo: string; cls: string }[] = [];
|
|
235
341
|
let unmeteredRuns = 0;
|
|
236
342
|
const unmeteredByRepo = new Map<string, number>();
|
|
343
|
+
const byModel = new Map<string, ModelBucket>();
|
|
237
344
|
|
|
238
345
|
for (const chain of chains.values()) {
|
|
239
346
|
// One issue merges once; if a weird history ever carried two merged rows,
|
|
@@ -262,6 +369,14 @@ export function computeStats(args: {
|
|
|
262
369
|
unmeteredRuns += 1;
|
|
263
370
|
unmeteredByRepo.set(run.repo, (unmeteredByRepo.get(run.repo) ?? 0) + 1);
|
|
264
371
|
}
|
|
372
|
+
// Attributed over exactly the runs this report already counts, so the
|
|
373
|
+
// per-model breakdown adds up to the totals printed beside it. Grouped on
|
|
374
|
+
// the *resolved* model: that is the one that was billed.
|
|
375
|
+
const model = modelBucket(byModel, run.resolvedModel ?? UNRESOLVED_MODEL);
|
|
376
|
+
model.runs += 1;
|
|
377
|
+
if (run.state === "merged") model.merges += 1;
|
|
378
|
+
if (run.spendUsd > 0) model.spend += run.spendUsd;
|
|
379
|
+
else model.unmetered += 1;
|
|
265
380
|
if (merged === undefined && FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs) {
|
|
266
381
|
failureRuns.push({ repo: run.repo, cls: run.failureClass ?? UNCLASSIFIED });
|
|
267
382
|
}
|
|
@@ -297,6 +412,21 @@ export function computeStats(args: {
|
|
|
297
412
|
)
|
|
298
413
|
.sort((a, b) => b.merged - a.merged || b.settled - a.settled || a.repo.localeCompare(b.repo));
|
|
299
414
|
|
|
415
|
+
const models = [...byModel.entries()]
|
|
416
|
+
.map(([model, b]) => ({
|
|
417
|
+
model,
|
|
418
|
+
runs: b.runs,
|
|
419
|
+
merges: b.merges,
|
|
420
|
+
spendUsd: cents(b.spend),
|
|
421
|
+
// No merge to divide by is "not measured", never a zero and never a
|
|
422
|
+
// division by zero — the same rule the per-issue cost follows.
|
|
423
|
+
spendPerMerge: b.merges === 0 ? null : cents(b.spend / b.merges),
|
|
424
|
+
unmeteredRuns: b.unmetered,
|
|
425
|
+
}))
|
|
426
|
+
.sort((a, b) => b.runs - a.runs || b.spendUsd - a.spendUsd || a.model.localeCompare(b.model));
|
|
427
|
+
const sessions = summarizeSessions(args.sessions ?? []);
|
|
428
|
+
const groom = sessions.find((s) => s.role === "groom");
|
|
429
|
+
|
|
300
430
|
return {
|
|
301
431
|
project: args.project,
|
|
302
432
|
window: args.window,
|
|
@@ -304,9 +434,63 @@ export function computeStats(args: {
|
|
|
304
434
|
empty: outcomes.length === 0,
|
|
305
435
|
total: summarize({ repo: "(all)", outcomes, failureRuns, unmeteredRuns }),
|
|
306
436
|
repos,
|
|
437
|
+
models,
|
|
438
|
+
sessions,
|
|
439
|
+
groomSpendUsd: groom?.spendUsd ?? 0,
|
|
440
|
+
groomUnmeteredSessions: groom?.unmeteredSessions ?? 0,
|
|
441
|
+
unmeteredRoles: [...UNMETERED_ROLES],
|
|
307
442
|
};
|
|
308
443
|
}
|
|
309
444
|
|
|
445
|
+
/** One model's accumulating totals while the chains are walked. */
|
|
446
|
+
interface ModelBucket {
|
|
447
|
+
runs: number;
|
|
448
|
+
merges: number;
|
|
449
|
+
spend: number;
|
|
450
|
+
unmetered: number;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function modelBucket(map: Map<string, ModelBucket>, model: string): ModelBucket {
|
|
454
|
+
let found = map.get(model);
|
|
455
|
+
if (found === undefined) {
|
|
456
|
+
found = { runs: 0, merges: 0, spend: 0, unmetered: 0 };
|
|
457
|
+
map.set(model, found);
|
|
458
|
+
}
|
|
459
|
+
return found;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Roles the fleet runs whose spend nothing can observe, so no row exists to
|
|
464
|
+
* sum. The orchestrator session is the case: its tick extension reads content
|
|
465
|
+
* text out of `message_start` and no usage event at all, so there is no number
|
|
466
|
+
* to record. Named in the report rather than left out, because a total that
|
|
467
|
+
* silently omits a whole role is the misreading this surface exists to prevent.
|
|
468
|
+
*/
|
|
469
|
+
const UNMETERED_ROLES = ["orchestrator"] as const;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Daemon-owned session rows reduced per role, in `SESSION_SPEND_ROLES` order so
|
|
473
|
+
* the shape is stable whether or not a role ran in the window.
|
|
474
|
+
*
|
|
475
|
+
* A session whose `spendUsd` is absent is counted in `unmeteredSessions` and
|
|
476
|
+
* contributes nothing to `spendUsd`: the store's contract distinguishes "the
|
|
477
|
+
* provider reported no cost" from "the provider reported zero", and collapsing
|
|
478
|
+
* them here would throw away the only place that distinction exists.
|
|
479
|
+
*/
|
|
480
|
+
function summarizeSessions(rows: readonly SessionSpendRow[]): SessionRoleStats[] {
|
|
481
|
+
return SESSION_SPEND_ROLES.map((role) => {
|
|
482
|
+
const mine = rows.filter((r) => r.role === role);
|
|
483
|
+
const metered = mine.filter((r) => r.spendUsd !== undefined);
|
|
484
|
+
return {
|
|
485
|
+
role,
|
|
486
|
+
sessions: mine.length,
|
|
487
|
+
turns: mine.reduce((sum, r) => sum + r.turns, 0),
|
|
488
|
+
spendUsd: cents(metered.reduce((sum, r) => sum + (r.spendUsd ?? 0), 0)),
|
|
489
|
+
unmeteredSessions: mine.length - metered.length,
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
310
494
|
/** `N` and its unit, for a count line that must not print "0" as a measure. */
|
|
311
495
|
function countLine(n: number, noun: string, plural: string): string {
|
|
312
496
|
return `${n} ${n === 1 ? noun : plural}`;
|
|
@@ -350,17 +534,88 @@ function renderRepo(repo: RepoStats, indent: string): string {
|
|
|
350
534
|
return lines.join("\n");
|
|
351
535
|
}
|
|
352
536
|
|
|
537
|
+
/**
|
|
538
|
+
* The attribution block (Phase 4): where the money went, as opposed to what it
|
|
539
|
+
* bought. Rendered from the same report `--json` carries, and rendered even in
|
|
540
|
+
* the empty case — a dry queue is exactly when daemon-owned grooming runs, so
|
|
541
|
+
* hiding this behind "nothing settled" would hide the spend that had no
|
|
542
|
+
* outcome, which is the one an operator most wants to see.
|
|
543
|
+
*/
|
|
544
|
+
function renderAttribution(report: StatsReport, indent: string): string[] {
|
|
545
|
+
const lines: string[] = [];
|
|
546
|
+
if (report.models.length > 0) {
|
|
547
|
+
lines.push(`${indent}per model:`);
|
|
548
|
+
for (const m of report.models) {
|
|
549
|
+
// Nothing metered at all is stated as unknown, not as "$0.00 metered":
|
|
550
|
+
// the dollar figure is true but reads as free, which is the exact
|
|
551
|
+
// misreading this whole surface exists to prevent.
|
|
552
|
+
const cost =
|
|
553
|
+
m.spendUsd === 0 && m.unmeteredRuns === m.runs
|
|
554
|
+
? `cost unknown — no run on this model metered`
|
|
555
|
+
: m.spendPerMerge === null
|
|
556
|
+
? `$${m.spendUsd.toFixed(2)} metered (no merge on this model to divide by)`
|
|
557
|
+
: `$${m.spendUsd.toFixed(2)} metered ($${m.spendPerMerge.toFixed(2)}/merge)`;
|
|
558
|
+
const unmetered =
|
|
559
|
+
m.unmeteredRuns === 0 || m.unmeteredRuns === m.runs
|
|
560
|
+
? ""
|
|
561
|
+
: ` · ${countLine(m.unmeteredRuns, "run", "runs")} unmetered`;
|
|
562
|
+
lines.push(
|
|
563
|
+
`${indent} ${m.model} ${countLine(m.runs, "run", "runs")}, ` +
|
|
564
|
+
`${countLine(m.merges, "merge", "merges")} ${cost}${unmetered}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const ran = report.sessions.filter((s) => s.sessions > 0);
|
|
569
|
+
// Silence rather than a row of zeros: "no grooming sessions in this window"
|
|
570
|
+
// and "grooming cost nothing" are different claims, and only the first is true.
|
|
571
|
+
if (ran.length > 0) {
|
|
572
|
+
lines.push(`${indent}daemon sessions:`);
|
|
573
|
+
for (const s of ran) {
|
|
574
|
+
// Same rule as the per-model line: an unmetered session's cost is
|
|
575
|
+
// unknown, so it is a count, never a zero folded into the dollar figure.
|
|
576
|
+
if (s.spendUsd === 0 && s.unmeteredSessions === s.sessions) {
|
|
577
|
+
lines.push(
|
|
578
|
+
`${indent} ${s.role} ${countLine(s.sessions, "session", "sessions")}, ` +
|
|
579
|
+
`${countLine(s.turns, "turn", "turns")} cost unknown — no session metered`,
|
|
580
|
+
);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const unknown =
|
|
584
|
+
s.unmeteredSessions === 0
|
|
585
|
+
? ""
|
|
586
|
+
: ` · ${countLine(s.unmeteredSessions, "session", "sessions")} unmetered (cost unknown, not $0.00)`;
|
|
587
|
+
lines.push(
|
|
588
|
+
`${indent} ${s.role} ${countLine(s.sessions, "session", "sessions")}, ` +
|
|
589
|
+
`${countLine(s.turns, "turn", "turns")} $${s.spendUsd.toFixed(2)} metered${unknown}`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (report.unmeteredRoles.length > 0) {
|
|
594
|
+
lines.push(
|
|
595
|
+
`${indent} ${report.unmeteredRoles.join(", ")} unmetered — no usage events are observable for ` +
|
|
596
|
+
`${report.unmeteredRoles.length === 1 ? "this session" : "these sessions"}, so the cost is unknown and no figure above includes it`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return lines;
|
|
600
|
+
}
|
|
601
|
+
|
|
353
602
|
/** Render the human form of the same {@link StatsReport} `--json` prints. */
|
|
354
603
|
export function renderStatsHuman(report: StatsReport): string {
|
|
355
604
|
const w = report.window;
|
|
356
605
|
const header = `${report.project} — outcomes ${w.sinceDay} → ${w.untilDay}`;
|
|
357
606
|
if (report.empty) {
|
|
358
|
-
return
|
|
607
|
+
return [
|
|
608
|
+
header,
|
|
609
|
+
" nothing settled in the window — no runs recorded, so there are no measurements to report yet (a fresh store or an idle fleet).",
|
|
610
|
+
...renderAttribution(report, " "),
|
|
611
|
+
"",
|
|
612
|
+
].join("\n");
|
|
359
613
|
}
|
|
360
614
|
const lines = [header, renderRepo(report.total, " "), ` gh api ${report.ghCalls} tracked ${report.ghCalls === 1 ? "call" : "calls"} in the window`];
|
|
361
615
|
if (report.repos.length > 0) {
|
|
362
616
|
lines.push(" per repo:");
|
|
363
617
|
for (const repo of report.repos) lines.push(renderRepo(repo, " "));
|
|
364
618
|
}
|
|
619
|
+
lines.push(...renderAttribution(report, " "));
|
|
365
620
|
return `${lines.join("\n")}\n`;
|
|
366
621
|
}
|
package/src/status-render.ts
CHANGED
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
|
|
20
20
|
import { formatZonedMinute } from "./availability.ts";
|
|
21
21
|
import { spendTelemetryDetail, type SpendTelemetryVerdict } from "./spend-telemetry.ts";
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
DaemonStop,
|
|
24
|
+
GroomingRecord,
|
|
25
|
+
InstallSurfaceObservation,
|
|
26
|
+
ReviewCorrectionRound,
|
|
27
|
+
RunRecord,
|
|
28
|
+
} from "./types.ts";
|
|
23
29
|
import { settlementFlagSummary } from "./diff-flags.ts";
|
|
24
30
|
import type { CodeGraphHealth } from "./graph-health.ts";
|
|
25
31
|
import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
|
|
@@ -28,6 +34,12 @@ import { planUsageLine } from "./usage.ts";
|
|
|
28
34
|
import { HEALTH_TIMEOUT_MS, SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
|
|
29
35
|
import type { WorkerPauseView } from "./fleet.ts";
|
|
30
36
|
import { formatRss, rssBytesFromHealthz } from "./host.ts";
|
|
37
|
+
// One constant, two readers: the durable in-flight to-spec launch marker
|
|
38
|
+
// (#777) is written by the daemon's grooming launcher and read here, so the
|
|
39
|
+
// reason string is imported rather than hand-copied — the copy that used to
|
|
40
|
+
// live in this file was a second source of truth for a row shape nobody
|
|
41
|
+
// audits.
|
|
42
|
+
import { TO_SPEC_IN_FLIGHT_REASON } from "./groom.ts";
|
|
31
43
|
import {
|
|
32
44
|
formatBaseHealth,
|
|
33
45
|
formatDispatchSummary,
|
|
@@ -242,20 +254,18 @@ const MECHANICAL_GROOMING_REASONS: Record<string, true> = {
|
|
|
242
254
|
};
|
|
243
255
|
|
|
244
256
|
/**
|
|
245
|
-
* The to-spec refusal classes persisted as blocked rows (#772) — a
|
|
246
|
-
* that failed validation
|
|
247
|
-
*
|
|
257
|
+
* The to-spec refusal classes persisted as blocked rows (#772, #1064) — a
|
|
258
|
+
* result that failed validation, or a pass that produced no answer at all, is
|
|
259
|
+
* a mechanical block, never a verdict. Mirrors the failure kinds of
|
|
260
|
+
* `ToSpecFailure` in `to-spec.ts`.
|
|
248
261
|
*/
|
|
249
262
|
const REFUSED_GROOMING_REASONS: Record<string, true> = {
|
|
250
263
|
malformed: true,
|
|
264
|
+
"no-answer": true,
|
|
251
265
|
"missing-source": true,
|
|
252
266
|
"stale-source": true,
|
|
253
267
|
};
|
|
254
268
|
|
|
255
|
-
/** The durable in-flight launch marker (#777) — a batch is running right now.
|
|
256
|
-
* Mirrors `TO_SPEC_IN_FLIGHT_REASON` in `orchestrator-tick.ts`. */
|
|
257
|
-
const GROOMING_IN_FLIGHT_REASON = "in-flight";
|
|
258
|
-
|
|
259
269
|
/**
|
|
260
270
|
* One project's durable grooming state as status lines, or nothing when there
|
|
261
271
|
* is nothing to report (#809).
|
|
@@ -281,10 +291,12 @@ const GROOMING_IN_FLIGHT_REASON = "in-flight";
|
|
|
281
291
|
* (`blocked` rows whose reason is a groomer verdict or a product-judgement
|
|
282
292
|
* label, e.g. `needs-product-decision`).
|
|
283
293
|
* - `mechanically blocked` — admission's lane/dependency holds.
|
|
284
|
-
* - `refused` — to-spec
|
|
285
|
-
* `missing-source`, `stale-source`), told apart from the
|
|
286
|
-
* operator sees whether the runway cannot move or a result
|
|
287
|
-
* trusted.
|
|
294
|
+
* - `refused` — to-spec passes that produced no usable verdict (`malformed`,
|
|
295
|
+
* `no-answer`, `missing-source`, `stale-source`), told apart from the
|
|
296
|
+
* holds so an operator sees whether the runway cannot move or a result
|
|
297
|
+
* cannot be trusted. Each row's line names its own class (`#19 malformed`,
|
|
298
|
+
* `#22 no-answer`), so a run of identical refusals is visible as a pattern
|
|
299
|
+
* rather than a wall of one word.
|
|
288
300
|
* - `in-flight` — a launched batch is running (#777).
|
|
289
301
|
* - `operator-parked` — the dispatch snapshot's parked count (#507).
|
|
290
302
|
*
|
|
@@ -332,7 +344,7 @@ export function formatGroomingStatus(input: GroomingStatusInput): string | undef
|
|
|
332
344
|
row.verdict === "blocked" &&
|
|
333
345
|
MECHANICAL_GROOMING_REASONS[row.reason] !== true &&
|
|
334
346
|
REFUSED_GROOMING_REASONS[row.reason] !== true &&
|
|
335
|
-
row.reason !==
|
|
347
|
+
row.reason !== TO_SPEC_IN_FLIGHT_REASON,
|
|
336
348
|
);
|
|
337
349
|
const mechanical = records.filter(
|
|
338
350
|
(row) => row.verdict === "blocked" && MECHANICAL_GROOMING_REASONS[row.reason] === true,
|
|
@@ -341,7 +353,7 @@ export function formatGroomingStatus(input: GroomingStatusInput): string | undef
|
|
|
341
353
|
(row) => row.verdict === "blocked" && REFUSED_GROOMING_REASONS[row.reason] === true,
|
|
342
354
|
);
|
|
343
355
|
const inFlight = records.filter(
|
|
344
|
-
(row) => row.verdict === "blocked" && row.reason ===
|
|
356
|
+
(row) => row.verdict === "blocked" && row.reason === TO_SPEC_IN_FLIGHT_REASON,
|
|
345
357
|
);
|
|
346
358
|
const awaiting = Math.max(0, routed - records.length);
|
|
347
359
|
const lines: string[] = [];
|
|
@@ -662,6 +674,143 @@ function formatSiblingLive(siblings: { project: string; live: number }[]): strin
|
|
|
662
674
|
return `shared daemon also serves ${siblings.length} other project(s): ${counts}`;
|
|
663
675
|
}
|
|
664
676
|
|
|
677
|
+
// review-correction provenance (#1048)
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* How many rounds of one run's correction chain print. A review ceiling is low
|
|
682
|
+
* single digits, so this bounds a pathological row rather than routinely
|
|
683
|
+
* truncating — and the last line printed says how many rounds it stands for,
|
|
684
|
+
* because a silently shortened chain is how a repeating pattern hides.
|
|
685
|
+
*/
|
|
686
|
+
const CORRECTION_CHAIN_LINES = 3;
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* A session lineage as a status row carries it. A ref is a session file path,
|
|
690
|
+
* and the leading directories are the same for every session in a fleet: the
|
|
691
|
+
* identifying half is the run and the file, so the line prints those and marks
|
|
692
|
+
* what it dropped with `…/` — the same honesty `firstLine` uses above, and the
|
|
693
|
+
* full ref stays in the durable row this line is projected from. Two lineages
|
|
694
|
+
* plus two models on one row is already at the width `omp-conductor status`
|
|
695
|
+
* can be read at; a full 60-character path twice is not.
|
|
696
|
+
*
|
|
697
|
+
* An absent ref reads `unrecorded`: rows written before #1045 know nothing
|
|
698
|
+
* about their own launch, and saying so is the only honest projection of one.
|
|
699
|
+
*/
|
|
700
|
+
function correctionRef(ref: string | undefined): string {
|
|
701
|
+
const value = ref?.trim() ?? "";
|
|
702
|
+
if (value === "") return "unrecorded";
|
|
703
|
+
const segments = value.split("/").filter((segment) => segment !== "");
|
|
704
|
+
const tail = segments.slice(-2).join("/");
|
|
705
|
+
return firstLine(segments.length > 2 ? `…/${tail}` : value, 60);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The models of one correction round: what the daemon asked for and what the
|
|
710
|
+
* harness actually resolved. Those two differing is the whole finding — in
|
|
711
|
+
* #1035 the fleet had already switched worker models while every correction
|
|
712
|
+
* round replayed the exhausted session under the old one — so the arrow form is
|
|
713
|
+
* kept whenever they differ and collapsed only when they agree.
|
|
714
|
+
*
|
|
715
|
+
* A resumed round is worded differently on purpose. `resolvedModel` is written
|
|
716
|
+
* only by `Store.recordReviewCorrectionSession`, which refuses a round decided
|
|
717
|
+
* `resume-original` (a resumed round *is* its origin session), so a resume
|
|
718
|
+
* durably has a request and no resolution. Rendering that through the fresh
|
|
719
|
+
* wording — `requested X → ran unrecorded` — would read as a launch that lost
|
|
720
|
+
* its model, when what actually happened is that the daemon asked the harness
|
|
721
|
+
* to continue an existing transcript on X and nothing re-recorded the
|
|
722
|
+
* resolution.
|
|
723
|
+
*
|
|
724
|
+
* Every branch reads the round's OWN recorded values. The project config is
|
|
725
|
+
* deliberately not a parameter: rendering it would make a round dispatched
|
|
726
|
+
* under a since-replaced model report the replacement, which is exactly the
|
|
727
|
+
* silent fake #1048 names.
|
|
728
|
+
*/
|
|
729
|
+
function correctionModels(round: ReviewCorrectionRound): string {
|
|
730
|
+
const { requestedModel: requested, resolvedModel: resolved } = round;
|
|
731
|
+
if (round.launchMode === "resume-original") {
|
|
732
|
+
if (resolved !== undefined)
|
|
733
|
+
return requested === undefined || requested === resolved
|
|
734
|
+
? `ran ${resolved}`
|
|
735
|
+
: `ran ${resolved} (asked ${requested})`;
|
|
736
|
+
if (requested !== undefined)
|
|
737
|
+
return round.state === "pending"
|
|
738
|
+
? `to continue on ${requested}, not launched yet`
|
|
739
|
+
: `asked to continue on ${requested}`;
|
|
740
|
+
return "model unrecorded";
|
|
741
|
+
}
|
|
742
|
+
if (requested !== undefined && resolved !== undefined)
|
|
743
|
+
return requested === resolved ? `model ${resolved} as requested` : `requested ${requested} → ran ${resolved}`;
|
|
744
|
+
if (requested !== undefined)
|
|
745
|
+
return round.state === "pending"
|
|
746
|
+
? `requested ${requested}, not launched yet`
|
|
747
|
+
: `requested ${requested} → ran unrecorded`;
|
|
748
|
+
// A resolution with no request: the harness named what it ran, the decision
|
|
749
|
+
// row that asked for it predates #1045. Still not a guess in either direction.
|
|
750
|
+
if (resolved !== undefined) return `ran ${resolved} (request unrecorded)`;
|
|
751
|
+
return "model unrecorded";
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* One run's review-correction chain as status lines (#1048), newest round
|
|
756
|
+
* first, six-space indented like every other run annotation.
|
|
757
|
+
*
|
|
758
|
+
* Before this, a correction round rendered as `review-revision N` and a
|
|
759
|
+
* dispatch age: an operator could not tell whether conductor had resumed the
|
|
760
|
+
* diagnosed implementation transcript or launched a fresh correction, nor under
|
|
761
|
+
* which model — which is why #1035 read as idle while an exhausted session
|
|
762
|
+
* consumed every round the fleet's new model was never asked to run.
|
|
763
|
+
*
|
|
764
|
+
* The per-line `current`/`prior` marker is where the runtime/history split
|
|
765
|
+
* lives: `current` is the newest round that has not settled, the one whose
|
|
766
|
+
* elapsed time and cumulative turns the run line above reports; every other
|
|
767
|
+
* line is chain history and carries no runtime at all. That is deliberately a
|
|
768
|
+
* property of each line rather than a header, because a header explaining how
|
|
769
|
+
* to read the following lines is a line nobody reads.
|
|
770
|
+
*
|
|
771
|
+
* Exported so the projection can be asserted directly on hand-built rounds:
|
|
772
|
+
* the interesting cases (a differing requested/resolved pair, a legacy row with
|
|
773
|
+
* no provenance) are properties of one row, not of a whole fleet.
|
|
774
|
+
*/
|
|
775
|
+
export function formatReviewCorrections(rounds: readonly ReviewCorrectionRound[]): string[] {
|
|
776
|
+
if (rounds.length === 0) return [];
|
|
777
|
+
const newestFirst = [...rounds].sort((a, b) => b.round - a.round);
|
|
778
|
+
const shown = newestFirst.slice(0, CORRECTION_CHAIN_LINES);
|
|
779
|
+
const hidden = newestFirst.length - shown.length;
|
|
780
|
+
return shown.map((round, index) => {
|
|
781
|
+
const current = index === 0 && round.state !== "settled";
|
|
782
|
+
// The round's lifecycle as one phrase. `queued` covers both a round nobody
|
|
783
|
+
// has dispatched yet and one a restart returned to the queue — durably they
|
|
784
|
+
// are the same row, and status must not invent a distinction the store does
|
|
785
|
+
// not keep.
|
|
786
|
+
const state =
|
|
787
|
+
round.state === "settled"
|
|
788
|
+
? `settled ${round.outcome ?? "outcome unrecorded"}`
|
|
789
|
+
: round.state === "pending"
|
|
790
|
+
? "queued"
|
|
791
|
+
: "dispatched";
|
|
792
|
+
const provenance =
|
|
793
|
+
round.launchMode === undefined
|
|
794
|
+
? // A row written before #1045 recorded no launch decision. Status says
|
|
795
|
+
// so and stops: the alternative — reading today's configuration back
|
|
796
|
+
// as though it had been this round's — is a fabricated provenance,
|
|
797
|
+
// and an operator auditing #1035 would have believed it.
|
|
798
|
+
"provenance unknown (pre-#1045 round)"
|
|
799
|
+
: round.launchMode === "resume-original"
|
|
800
|
+
? // A resume continues one lineage, so there is one session to name:
|
|
801
|
+
// the implementation transcript the review diagnosed.
|
|
802
|
+
`resume-original resumed session ${correctionRef(round.originSessionRef)} ${correctionModels(round)}`
|
|
803
|
+
: // A fresh correction has two: the transcript that was reviewed, and
|
|
804
|
+
// the new session doing the correcting. Naming only one of them is
|
|
805
|
+
// how "fresh" became indistinguishable from "resumed".
|
|
806
|
+
`fresh-correction new session ${correctionRef(round.correctionSessionRef)} (diagnosed ${correctionRef(
|
|
807
|
+
round.originSessionRef,
|
|
808
|
+
)}) ${correctionModels(round)}`;
|
|
809
|
+
const tail = index === shown.length - 1 && hidden > 0 ? ` (+${hidden} earlier round(s))` : "";
|
|
810
|
+
return ` correction round ${round.round} ${current ? "current" : "prior"} (${state}) ${provenance}${tail}`;
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
665
814
|
|
|
666
815
|
function formatProjectBody(
|
|
667
816
|
s: StatusSnapshot,
|
|
@@ -867,6 +1016,12 @@ function formatProjectBody(
|
|
|
867
1016
|
: "failed at this head"
|
|
868
1017
|
} — unresolved findings; push a corrected head, or record a conductor_pr_review_clear for this exact head, before merge`,
|
|
869
1018
|
);
|
|
1019
|
+
// The correction chain (#1048), rendered from the same annotation helper as
|
|
1020
|
+
// the merge blocker above so it reaches BOTH surfaces that show a run: the
|
|
1021
|
+
// live lease and the preserved artifact. A round whose worker has finished
|
|
1022
|
+
// leaves the lease list, and that is precisely when an operator asks what
|
|
1023
|
+
// the last correction actually did.
|
|
1024
|
+
out.push(...formatReviewCorrections(s.reviewCorrections?.[r.id] ?? []));
|
|
870
1025
|
return out;
|
|
871
1026
|
};
|
|
872
1027
|
if (s.activeRuns.length === 0) {
|