omp-conductor 0.19.6 → 0.20.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/REFERENCE.md +27 -2
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -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 +253 -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 +113 -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 +412 -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 +736 -0
- package/src/daemon/settle-pass.ts +589 -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 -7832
- 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 +24 -3
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- 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 +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- 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 +158 -7
- package/src/store.ts +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/setup.ts
CHANGED
|
@@ -21,12 +21,14 @@
|
|
|
21
21
|
* `src/gh.ts` exposing `gh()` (throwing) over `ghTry()` (classifying).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
|
-
import { dirname, join } from "node:path";
|
|
26
|
+
import { dirname, join, relative } from "node:path";
|
|
27
27
|
import { parse as parseYaml } from "yaml";
|
|
28
28
|
import {
|
|
29
|
+
AGENTS_BRIEF_NAME,
|
|
29
30
|
COMPOSE_BANNER,
|
|
31
|
+
CONSOLE_BRIEF_NAME,
|
|
30
32
|
ORCHESTRATOR_BRIEF_NAME,
|
|
31
33
|
POLICY_BRIEF_NAME,
|
|
32
34
|
composeOrchestrator,
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
} from "./config.ts";
|
|
52
54
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
53
55
|
import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
|
|
56
|
+
import { shellQuote } from "./shell.ts";
|
|
54
57
|
import {
|
|
55
58
|
CONFIG_VERSION,
|
|
56
59
|
DEFAULT_ARM_PROOF,
|
|
@@ -168,6 +171,12 @@ export interface SetupAnswers {
|
|
|
168
171
|
* to survive an amend of some other area (#369).
|
|
169
172
|
*/
|
|
170
173
|
groomBelow?: GroomTrigger;
|
|
174
|
+
/**
|
|
175
|
+
* The OMP model role the daemon's own grooming scouts run under (#1041).
|
|
176
|
+
* Hand-edited like {@link groomBelow} — the wizard never asks — so it exists
|
|
177
|
+
* here only to survive an amend of some other area (#369).
|
|
178
|
+
*/
|
|
179
|
+
groomRole?: string;
|
|
171
180
|
/**
|
|
172
181
|
* Ordered fallback worker models and the provider-failure threshold that
|
|
173
182
|
* starts them. The wizard never asks for either — they are hand-edited —
|
|
@@ -583,7 +592,7 @@ export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
|
|
|
583
592
|
"operator can take it back by editing this paragraph.",
|
|
584
593
|
};
|
|
585
594
|
|
|
586
|
-
export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
|
|
595
|
+
export { AGENTS_BRIEF_NAME, CONSOLE_BRIEF_NAME, ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
|
|
587
596
|
|
|
588
597
|
/** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
|
|
589
598
|
const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
|
|
@@ -591,6 +600,13 @@ const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator
|
|
|
591
600
|
/** Shipped POLICY.md scaffold — Releases, Project context, Reporting, Amendments. */
|
|
592
601
|
const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
|
|
593
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Shipped console floor — the operator console's role, delivery contract,
|
|
605
|
+
* authority and boundaries. Render-only: it has no `POLICY.md` half, is never
|
|
606
|
+
* composed, and is never classified by `inspectBriefLayout`.
|
|
607
|
+
*/
|
|
608
|
+
const CONSOLE_TEMPLATE_PATH = join(import.meta.dir, "briefs", "console.md");
|
|
609
|
+
|
|
594
610
|
/**
|
|
595
611
|
* `repo` writes labels and closes issues; `project` moves cards on the board.
|
|
596
612
|
* Both are load-bearing for an unattended loop, so a missing one is reported
|
|
@@ -1014,6 +1030,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
1014
1030
|
: {}),
|
|
1015
1031
|
// A documented hand-edited key, so an unrelated amend must not delete it.
|
|
1016
1032
|
...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
|
|
1033
|
+
// Same contract for the daemon's grooming-scout role: hand-edited, and an
|
|
1034
|
+
// amend that dropped it would silently move grooming back to the harness
|
|
1035
|
+
// default model (#1041).
|
|
1036
|
+
...(a.groomRole === undefined ? {} : { groomRole: a.groomRole }),
|
|
1017
1037
|
// The failover chain is hand-edited too: dropping it on an unrelated amend
|
|
1018
1038
|
// would pin every run of the project onto a dead provider again (#286).
|
|
1019
1039
|
...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
|
|
@@ -1256,6 +1276,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
1256
1276
|
// what keeps the rewritten config identical to the one that was read.
|
|
1257
1277
|
if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
|
|
1258
1278
|
if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
|
|
1279
|
+
if (p.groomRole !== undefined) answers.groomRole = p.groomRole;
|
|
1259
1280
|
if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
|
|
1260
1281
|
if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
|
|
1261
1282
|
if (p.workerEscalationModel !== undefined) answers.workerEscalationModel = p.workerEscalationModel;
|
|
@@ -1365,6 +1386,205 @@ export function renderPolicyForProject(p: ProjectConfig): string {
|
|
|
1365
1386
|
return renderBriefTemplate(shippedPolicyTemplate(), briefVarsForProject(p));
|
|
1366
1387
|
}
|
|
1367
1388
|
|
|
1389
|
+
// ------------------------------------------------------- the operator console
|
|
1390
|
+
//
|
|
1391
|
+
// The console is the second session of the two-session split: the tick brain
|
|
1392
|
+
// keeps its pane and never receives an operator turn, and this one owns the
|
|
1393
|
+
// Telegram conversation 24/7. Everything below provisions its workspace, and
|
|
1394
|
+
// the whole point of the shape is what it does NOT do:
|
|
1395
|
+
//
|
|
1396
|
+
// - **No tick config.** The tick extension activates only when
|
|
1397
|
+
// `.conductor-tick.json` exists in the session cwd, so a console cwd
|
|
1398
|
+
// without one is inert by construction rather than by a runtime check that
|
|
1399
|
+
// could be got wrong. Nothing here ever writes that file.
|
|
1400
|
+
// - **No POLICY.md, no compose, no layout classification.** The floor is
|
|
1401
|
+
// rendered and written; there is no operator half to merge, so
|
|
1402
|
+
// `refreshComposedBrief` and `inspectBriefLayout` never see this file.
|
|
1403
|
+
// - **Derived, never stamped.** The cwd is a function of the state dir and
|
|
1404
|
+
// the project name, in TypeScript here and in the same shape in
|
|
1405
|
+
// `recover.sh`. A config key would be a second copy of the answer, and the
|
|
1406
|
+
// copy nobody updated is the one a pane would `cd` into.
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* The console session's own directory: `<stateDir>/console/<project>`.
|
|
1410
|
+
*
|
|
1411
|
+
* Under the state root rather than beside the project's worktrees because the
|
|
1412
|
+
* console is a fleet surface, not a checkout — and because a directory the
|
|
1413
|
+
* worker machinery never touches cannot be force-removed by a re-claim.
|
|
1414
|
+
*/
|
|
1415
|
+
export function consoleCwdForProject(p: ProjectConfig): string {
|
|
1416
|
+
return join(stateDir(), "console", p.name);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** The rendered console floor inside {@link consoleCwdForProject}. */
|
|
1420
|
+
export function consoleBriefPathForProject(p: ProjectConfig): string {
|
|
1421
|
+
return join(consoleCwdForProject(p), CONSOLE_BRIEF_NAME);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** Shipped console floor template, placeholders and all. */
|
|
1425
|
+
export function shippedConsoleTemplate(): string {
|
|
1426
|
+
return readFileSync(CONSOLE_TEMPLATE_PATH, "utf8");
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Rendered console floor for a configured project.
|
|
1431
|
+
*
|
|
1432
|
+
* The same `briefVarsForProject` coordinates the orchestrator floor gets, so a
|
|
1433
|
+
* placeholder either template names is filled from one table — a console that
|
|
1434
|
+
* shipped a literal `{{PROJECT}}` to a session would be reading a template,
|
|
1435
|
+
* not a brief.
|
|
1436
|
+
*/
|
|
1437
|
+
export function renderConsoleForProject(p: ProjectConfig): string {
|
|
1438
|
+
return renderBriefTemplate(shippedConsoleTemplate(), briefVarsForProject(p));
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* The shell line that launches the console pane, produced here so
|
|
1443
|
+
* `omp-conductor console` and `recover.sh` cannot print two different commands
|
|
1444
|
+
* for one surface.
|
|
1445
|
+
*
|
|
1446
|
+
* `dmOwner` is the Telegram DM force-claim, and it is a host-level judgement,
|
|
1447
|
+
* not a preference: one state dir may have exactly one DM owner, so a host
|
|
1448
|
+
* with several fleet cwds launches its consoles without it and lets inbound
|
|
1449
|
+
* messages arrive by forum-topic routing instead. Nothing else in this package
|
|
1450
|
+
* sets `OMP_TELEGRAM_DM_OWNER`.
|
|
1451
|
+
*/
|
|
1452
|
+
export function consolePaneCommand(opts: { cwd: string; dmOwner: boolean }): string {
|
|
1453
|
+
// Quoted because the reader copies what a surface prints: a state root under
|
|
1454
|
+
// a path with a space or a `$` must still `cd` where it says.
|
|
1455
|
+
return `cd ${shellQuote(opts.cwd)} && ${opts.dmOwner ? "OMP_TELEGRAM_DM_OWNER=1 " : ""}omp`;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* The `AGENTS.md` link in the console cwd and what provisioning will do about
|
|
1460
|
+
* it, with the same never-clobber discipline the fleet pane's link gets: an
|
|
1461
|
+
* already-correct symlink is kept, a stale one is replaced, and a **regular
|
|
1462
|
+
* file** is an operator's own brief — reported and left untouched.
|
|
1463
|
+
*/
|
|
1464
|
+
export interface ConsoleLinkPlan {
|
|
1465
|
+
path: string;
|
|
1466
|
+
/** Relative target inside the symlink — `CONSOLE.md`, its own directory. */
|
|
1467
|
+
target: string;
|
|
1468
|
+
action: "create" | "update" | "keep" | "skip";
|
|
1469
|
+
/** Why {@link action} is `skip`; present only then. */
|
|
1470
|
+
skippedReason?: string;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** What provisioning the console workspace will write, before it writes it. */
|
|
1474
|
+
export interface ConsolePlan {
|
|
1475
|
+
/** The directory the console pane runs in. Created by the write. */
|
|
1476
|
+
cwd: string;
|
|
1477
|
+
/** The rendered floor: `create` on a fresh cwd, `keep` when already current. */
|
|
1478
|
+
brief: { path: string; content: string; action: "create" | "update" | "keep" };
|
|
1479
|
+
link: ConsoleLinkPlan;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Plans the console workspace for a project. Pure: reads the filesystem,
|
|
1484
|
+
* writes nothing, so `setup host` can show the whole thing before consent and
|
|
1485
|
+
* `omp-conductor console` can report what it did.
|
|
1486
|
+
*/
|
|
1487
|
+
export function planConsole(p: ProjectConfig): ConsolePlan {
|
|
1488
|
+
const cwd = consoleCwdForProject(p);
|
|
1489
|
+
const briefPath = consoleBriefPathForProject(p);
|
|
1490
|
+
const content = renderConsoleForProject(p);
|
|
1491
|
+
const linkPath = join(cwd, AGENTS_BRIEF_NAME);
|
|
1492
|
+
// Relative, and inside the same directory, so the whole console cwd stays
|
|
1493
|
+
// movable — the same reason the fleet pane's link is relative.
|
|
1494
|
+
const target = relative(dirname(linkPath), briefPath);
|
|
1495
|
+
// An unreadable existing brief is planned as `update`: rewriting it is the
|
|
1496
|
+
// repair, and a plan that reported `keep` on a file it could not read would
|
|
1497
|
+
// leave the console without a floor.
|
|
1498
|
+
let live: string | undefined;
|
|
1499
|
+
try {
|
|
1500
|
+
live = readFileSync(briefPath, "utf8");
|
|
1501
|
+
} catch {
|
|
1502
|
+
live = undefined;
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
cwd,
|
|
1506
|
+
brief: {
|
|
1507
|
+
path: briefPath,
|
|
1508
|
+
content,
|
|
1509
|
+
action: live === undefined ? (existsSync(briefPath) ? "update" : "create") : live === content ? "keep" : "update",
|
|
1510
|
+
},
|
|
1511
|
+
link: planConsoleLink(linkPath, target),
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function planConsoleLink(path: string, target: string): ConsoleLinkPlan {
|
|
1516
|
+
let st;
|
|
1517
|
+
try {
|
|
1518
|
+
st = lstatSync(path);
|
|
1519
|
+
} catch {
|
|
1520
|
+
// ENOENT, and any unreadable parent, both collapse to a best-effort
|
|
1521
|
+
// create; a filesystem that refuses the link surfaces at write time.
|
|
1522
|
+
return { path, target, action: "create" };
|
|
1523
|
+
}
|
|
1524
|
+
if (st.isSymbolicLink()) {
|
|
1525
|
+
return { path, target, action: readlinkSync(path, "utf8") === target ? "keep" : "update" };
|
|
1526
|
+
}
|
|
1527
|
+
return {
|
|
1528
|
+
path,
|
|
1529
|
+
target,
|
|
1530
|
+
action: "skip",
|
|
1531
|
+
skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** What {@link writeConsole} wrote, plus anything it could not. */
|
|
1536
|
+
export interface ConsoleWrite {
|
|
1537
|
+
wrote: string[];
|
|
1538
|
+
/** Best-effort failures the caller surfaces; provisioning continues. */
|
|
1539
|
+
warnings: string[];
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Materialises a planned console workspace. Idempotent: a `keep` brief and a
|
|
1544
|
+
* `keep` link touch nothing, so re-running `omp-conductor console` or
|
|
1545
|
+
* `setup host` rewrites nothing and reports an empty write.
|
|
1546
|
+
*
|
|
1547
|
+
* The link is re-`lstat`ed here rather than trusted from the plan, because the
|
|
1548
|
+
* entry can change across the consent gap: an operator who drops their own
|
|
1549
|
+
* `AGENTS.md` where a stale symlink was planned for replacement must keep it.
|
|
1550
|
+
*/
|
|
1551
|
+
export function writeConsole(plan: ConsolePlan): ConsoleWrite {
|
|
1552
|
+
const wrote: string[] = [];
|
|
1553
|
+
const warnings: string[] = [];
|
|
1554
|
+
mkdirSync(plan.cwd, { recursive: true });
|
|
1555
|
+
if (plan.brief.action !== "keep") {
|
|
1556
|
+
writeFileSync(plan.brief.path, plan.brief.content);
|
|
1557
|
+
wrote.push(plan.brief.path);
|
|
1558
|
+
}
|
|
1559
|
+
if (plan.link.action === "create" || plan.link.action === "update") {
|
|
1560
|
+
const { path, target } = plan.link;
|
|
1561
|
+
try {
|
|
1562
|
+
let existing;
|
|
1563
|
+
try {
|
|
1564
|
+
existing = lstatSync(path);
|
|
1565
|
+
} catch {
|
|
1566
|
+
existing = undefined;
|
|
1567
|
+
}
|
|
1568
|
+
if (existing !== undefined && !existing.isSymbolicLink()) {
|
|
1569
|
+
warnings.push(
|
|
1570
|
+
`${path} is a regular file, not ours to overwrite; console AGENTS.md link skipped`,
|
|
1571
|
+
);
|
|
1572
|
+
} else if (existing !== undefined && readlinkSync(path, "utf8") === target) {
|
|
1573
|
+
// Already correct, however the plan read it.
|
|
1574
|
+
} else {
|
|
1575
|
+
if (existing !== undefined) rmSync(path, { force: true });
|
|
1576
|
+
symlinkSync(target, path);
|
|
1577
|
+
wrote.push(path);
|
|
1578
|
+
}
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
warnings.push(
|
|
1581
|
+
`could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return { wrote, warnings };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1368
1588
|
/**
|
|
1369
1589
|
* Composed session brief: rendered floor + shared policy (when present) +
|
|
1370
1590
|
* POLICY scaffold (or a caller's policy).
|
|
@@ -1811,6 +2031,12 @@ export function summarisePlan(
|
|
|
1811
2031
|
` ${"cap escalation".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerEscalationModel.trim()} (hand-edited)`,
|
|
1812
2032
|
);
|
|
1813
2033
|
}
|
|
2034
|
+
// The daemon spends real money grooming unattended (#1041), so which role it
|
|
2035
|
+
// spends it under belongs on the plan a human approves — hand-edited, like
|
|
2036
|
+
// the cap-escalation tier above it, and silent when nobody answered.
|
|
2037
|
+
if (a.groomRole !== undefined && a.groomRole.trim().length > 0) {
|
|
2038
|
+
lines.push(` ${"groom role".padEnd(CAPS_PLAN_KEY_PAD)}${a.groomRole.trim()} (hand-edited)`);
|
|
2039
|
+
}
|
|
1814
2040
|
|
|
1815
2041
|
lines.push("", "escalation");
|
|
1816
2042
|
if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
|
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
|
}
|