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.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
@@ -0,0 +1,539 @@
1
+ /**
2
+ * The seam every dispatch pass is written against.
3
+ *
4
+ * `Deps` is the reason this directory can exist at all: a pass takes the one
5
+ * struct and touches nothing else, so it can move to its own module without
6
+ * learning about its neighbours. That also makes this module the test of
7
+ * whether a cut is in the right place — a pass that needs something no `Deps`
8
+ * field can reach was split along the wrong line.
9
+ *
10
+ * Three kinds of thing live here and nothing else:
11
+ *
12
+ * - `Deps` itself, the gate and registry interfaces it is made of, and the
13
+ * intervals `runDaemon` schedules against. The gate types sit here rather
14
+ * than beside their implementations so `Deps` never imports a pass, and no
15
+ * pass imports another merely to name a field it already holds.
16
+ * - `verbDeps` and `recordDaemonSessionSpend`: the two projections of `Deps`
17
+ * that more than one pass performs. Duplicated they would be two answers to
18
+ * one question; parked inside one pass they would make the others import it
19
+ * sideways.
20
+ * - the pure identity and classification leaves the passes share — `repoSlug`,
21
+ * `knowledgeRepoKey`, `githubRepo`, `completionLastError`,
22
+ * `exhaustedSessionReason`. A shared floor with no upstream of its own is
23
+ * what keeps the rest of this directory acyclic.
24
+ *
25
+ * `PACKAGE_SRC_DIR` is the one declaration here that is not a move. Modules
26
+ * under `daemon/` sit one directory deeper than the code they came from, so
27
+ * every `import.meta.dir` read that meant "the installed `src/`" — the brief
28
+ * templates, the integrity manifest root, the two lines that print the package
29
+ * root — resolves through this constant rather than silently re-deriving a
30
+ * path one level too deep.
31
+ */
32
+ import { join } from "node:path";
33
+ import { findProject, loadConfig } from "../config.ts";
34
+ import type { CredentialClassProbeResult } from "../credential-class.ts";
35
+ import type { HerdrRun } from "../fleet.ts";
36
+ import { probeRunLane, readBaseChain, type CriticalBaseProbe, type RunLaneProbe } from "../gitops.ts";
37
+ import { errText, log } from "../log.ts";
38
+ import type { OrchestratorHandle } from "../orchestrator.ts";
39
+ import { isPaused, pausedAt } from "../pause.ts";
40
+ import type { UnroutableReason } from "../routing.ts";
41
+ import type { TelegramFreshness } from "../telegram-freshness.ts";
42
+ import type { Caps, Escalation, FailureClass, HostConstraints, IssueSnapshot, ProjectConfig, RepoTarget, RunRecord, Store, Tracker } from "../types.ts";
43
+ import type { InstalledSurfaces } from "../upgrade.ts";
44
+ import type { UsageSource } from "../usage.ts";
45
+ import { githubVerbActions } from "../verbs/actions.ts";
46
+ import type { VerbActions, VerbDeps } from "../verbs/server.ts";
47
+ import type { PeerReader } from "../verbs/socket.ts";
48
+ import type { AdjudicationResult, AdjudicatorOpts, RunWorkerDeps, ToSpecScoutOpts, ToSpecScoutResult, WorkerPauseControl, WorkerPausePhase } from "../worker.ts";
49
+
50
+ /**
51
+ * The installed `src/` — the directory `daemon.ts` used to be `import.meta.dir`
52
+ * of, before its passes moved one level down into `daemon/`.
53
+ *
54
+ * Three reads depend on that exact path and would break silently one directory
55
+ * deeper: the brief templates under `src/briefs/`, the integrity manifest root
56
+ * (which is the package's self-portrait, so a wrong root would hash a tenth of
57
+ * it and call the rest unchanged), and the two operator-facing lines that print
58
+ * the package root. One constant so all five sites keep the same answer.
59
+ */
60
+ export const PACKAGE_SRC_DIR = join(import.meta.dir, "..");
61
+
62
+ /** Long enough that the tracker is not polled raw, short enough that a human
63
+ * who labels an issue sees it picked up within a coffee break. */
64
+ export const TICK_INTERVAL_MS = 5 * 60_000;
65
+ export const GRAPH_HEALTH_INTERVAL_MS = 60_000;
66
+ /** Report delivery runs on its own timer, not the five-minute dispatch tick: a
67
+ * report an operator is waiting on must not sit in the outbox for the length of
68
+ * a poll interval, and delivery is owed even while claiming is paused (#123). */
69
+ export const REPORT_DELIVERY_INTERVAL_MS = 30_000;
70
+
71
+ export const DEFAULT_PORT = 8787;
72
+
73
+ /** Fleet-wide escalations still need an issue number in the payload; 0 is the
74
+ * sentinel that reads as "no issue" in every renderer. */
75
+ export const NO_ISSUE = 0;
76
+
77
+ export const UNROUTABLE_TEXT: Record<UnroutableReason, string> = {
78
+ "no-repo-label": "it carries no repo label",
79
+ "multiple-repo-labels": "it carries more than one repo label",
80
+ "unknown-repo": "its repo label maps to no configured repo",
81
+ };
82
+
83
+ export interface DaemonOpts {
84
+ once?: boolean;
85
+ port?: number;
86
+ project?: string;
87
+ }
88
+
89
+ /** Everything one tick touches. `project` and `caps` are re-resolved at each
90
+ * tick boundary so an operator's config edit applies on the next tick rather
91
+ * than the next daemon restart (#170); a tick and the runs it admits see one
92
+ * consistent snapshot, and a mid-run edit never changes a live run's labels,
93
+ * model or caps — `handleIssue` destructures them at dispatch time. The rest
94
+ * are resolved once at startup so a tick never re-reads config mid-flight and
95
+ * changes its own limits underneath itself. */
96
+ export interface DrainSignal {
97
+ draining: boolean;
98
+ }
99
+
100
+ export interface Deps {
101
+ project: ProjectConfig;
102
+ caps: Caps;
103
+ /** The typed host-constraints block (#721), re-resolved at the tick
104
+ * boundary like `project`/`caps`, and rendered into every brief the tick
105
+ * dispatches. Optional so tests that never exercise it can omit it. */
106
+ host?: HostConstraints;
107
+ tracker: Tracker;
108
+ store: Store;
109
+ /**
110
+ * Set true the moment a daemon stop (SIGTERM/SIGINT) begins. The run loop
111
+ * only sees `stopping` between whole ticks, so a pass that was already in
112
+ * flight when the stop landed must re-check this flag itself — before it
113
+ * claims and before it launches — or it creates exactly the work the
114
+ * shutdown is about to wait for and then lose to the stop timeout (#374).
115
+ * Optional only so tests that never exercise shutdown can omit it;
116
+ * `runDaemon` always wires the real one.
117
+ */
118
+ drain?: DrainSignal;
119
+ /** False after a live config reload fails; autonomous delivery then holds
120
+ * fail-closed until a later tick validates the config again. */
121
+ deliveryPolicyValid?: boolean;
122
+ /** Provider-reported plan allowance, cached with a TTL. Resolved once at
123
+ * startup like every other dep so a tick cannot swap its own meter. */
124
+ usage: UsageSource;
125
+ escalate(e: Escalation): Promise<void>;
126
+ turnLimits: TurnLimitRegistry;
127
+ workerControls: WorkerControlRegistry;
128
+ /** Session seam for lifecycle integration tests; production uses the real harness. */
129
+ workerDeps?: RunWorkerDeps;
130
+ /** Harness per-process logs used to classify terminal worker routes. */
131
+ harnessLogDir?: string;
132
+ /**
133
+ * The Herdr runner for the worker-representation surface (#1035). Production
134
+ * leaves it absent — the real `herdr` CLI answers; lifecycle tests inject a
135
+ * fake so convergence is proven without a terminal.
136
+ */
137
+ herdrRun?: HerdrRun;
138
+ integrity: IntegrityGate;
139
+ stall: StallGate;
140
+ /**
141
+ * Stops the wedged orchestrator by exact identity, for the bounded
142
+ * auto-restart ({@link restartWedgedOrchestrator}). Production leaves it
143
+ * absent and `stopConductorPane` answers — the same resolver `halt --pane`
144
+ * uses, so there is exactly one implementation of "which pane is the
145
+ * orchestrator". A test injects a fake, which is what makes the counter, the
146
+ * bound and the record-before-attempt ordering assertable without a terminal.
147
+ */
148
+ restartOrchestrator?: (
149
+ projectName: string,
150
+ ) => Promise<{ stopped: "herdr-agent" | "already-gone"; detail: string; agentName: string }>;
151
+ /**
152
+ * The embedded orchestrator session handle when one started; absent when it
153
+ * failed to start or the project uses an external orchestrator. Feeds the
154
+ * orchestrator-down reconcile ({@link reconcileOrchestratorDown}) so a
155
+ * crashed session pages once per incident instead of degrading quietly.
156
+ */
157
+ orchestrator?: OrchestratorHandle;
158
+ cleanup?: RetainedCleanupCursor;
159
+ /**
160
+ * Reads the connecting uid off a verb socket (#126). Resolved once at startup
161
+ * so the mechanism is logged before the first socket exists; absent on a host
162
+ * exposing no peer-credential call, where the `0600` socket under the
163
+ * daemon-owned `0711` parent is all that keeps other local accounts out, and
164
+ * `status` says so. It never told two sessions apart — they share the daemon's
165
+ * uid.
166
+ */
167
+ verbPeerReader?: PeerReader;
168
+ /**
169
+ * The privileged half the verbs call once their checks pass. Optional only so
170
+ * a test can build a `Deps` without a repository; `runDaemon` always wires
171
+ * the real one.
172
+ */
173
+ verbActions?: VerbActions;
174
+ /**
175
+ * The fleet-installs-itself verification pass (#486), run on the first tick
176
+ * after a detached upgrade. The default is the journal-gated
177
+ * {@link verifyPendingUpgradeTick}; a test injects its own to drive the tick
178
+ * without touching the host's packages, journal or outbox.
179
+ */
180
+ upgradeVerifier?: (d: Deps) => Promise<void>;
181
+ /**
182
+ * Answers whether a preserved continuation branch contains every configured
183
+ * critical-base marker, for the stale-base admission hold (#428). Wired by
184
+ * `runDaemon` to the mirror-backed {@link probeCriticalBase}; a test injects
185
+ * a fake. Absent, the stale-base gate fails closed whenever a project names
186
+ * a marker (a safety interlock must not silently weaken).
187
+ */
188
+ probeCriticalBase?: CriticalBaseProbe;
189
+ /**
190
+ * Reads one active run's file lane for the admission file-lane interlock
191
+ * (#555): the union of its uncommitted worktree changes and its branch-vs-base
192
+ * diff. Reconciliation is not occupancy: while the run merges its base, the
193
+ * probe reports only files it has actually diverged on, never the files the
194
+ * merge merely staged (#684). Wired by `runDaemon` to the mirror/worktree-backed
195
+ * {@link probeRunLane}; a test injects a fake. Absent, the interlock is inert
196
+ * (no lane is ever known occupied), which is the issue's "fail open": the
197
+ * gate adds holds, it never refuses a well-formed issue for lack of this
198
+ * probe the way `criticalBase` does.
199
+ */
200
+ probeWorktreeLane?: RunLaneProbe;
201
+ /**
202
+ * Reads one provider's current credential class from the harness, out of
203
+ * process (#852). Wired by `runDaemon` to {@link probeCredentialClass}; a test
204
+ * injects a fake.
205
+ *
206
+ * Absent, both fences refuse a project that declares `requireOauthProviders` —
207
+ * this one fails CLOSED, unlike `probeWorktreeLane`, because an unverified
208
+ * credential class costs exactly what a wrong one costs. A project declaring
209
+ * nothing never calls it, so an unwired test dispatches as it always has.
210
+ */
211
+ probeCredentialClass?: (provider: string) => Promise<CredentialClassProbeResult>;
212
+ /**
213
+ * Reads one issue's tracker state in a repository the admission tracker is
214
+ * not bound to — the cross-repo Depends-on interlock (#420). Wired by
215
+ * `runDaemon` to a repo-scoped tracker; a test injects a fake. Absent,
216
+ * admission fails a routed cross-repo prerequisite closed.
217
+ */
218
+ probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
219
+ /**
220
+ * Reads one issue's BODY in a repository the admission tracker is not bound
221
+ * to — the dependency-graph cycle pass (#421). Wired by `runDaemon` to a
222
+ * repo-scoped tracker; a test injects a fake. Absent, a routed reachable
223
+ * body fails that branch closed rather than synthesising a cycle.
224
+ */
225
+ probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
226
+ /**
227
+ * Reads the three install identities this host carries (#919). Wired by
228
+ * `runDaemon` to `inspectSurfaces`, the same seam `doctor` and `upgrade`
229
+ * read, so nothing re-implements the probe. A test injects its own; absent,
230
+ * the pass records nothing and every cheap surface honestly says "not
231
+ * observed yet" rather than claiming agreement.
232
+ */
233
+ probeInstallSurfaces?: () => Promise<InstalledSurfaces>;
234
+ /** The `omp-telegram` install/daemon/published triple, read on the same
235
+ * periodic pass as the surfaces above (#961). */
236
+ probeTelegramFreshness?: () => Promise<TelegramFreshness>;
237
+ /**
238
+ * Runs one review-ceiling adjudication (#932). Production is
239
+ * {@link runAdjudicator}; a test injects a fake so the assertion can be the
240
+ * launch arguments and the assembled brief — which is what this pass actually
241
+ * produces — rather than a stored flag.
242
+ */
243
+ runAdjudicatorImpl?: (opts: AdjudicatorOpts) => Promise<AdjudicationResult>;
244
+ /**
245
+ * Runs one to-spec grooming pass (#1041). Production is
246
+ * {@link runToSpecScout}; a test injects a fake, which is what makes the
247
+ * whole mechanical pipeline — select, claim, persist, gate, promote, wake —
248
+ * assertable end to end without a session or a network.
249
+ */
250
+ runToSpecScoutImpl?: (opts: ToSpecScoutOpts) => Promise<ToSpecScoutResult>;
251
+ /**
252
+ * The dispatch wake fired after a queue-label hand-back or a mechanical
253
+ * promotion (#1041) — the same seam `settlement.ts` reads, so one injected
254
+ * spy covers every requeue path a tick runs. Production leaves it absent and
255
+ * the real loopback client answers: from inside the daemon that is a POST to
256
+ * its own `/wake`, which is deliberate rather than a shortcut — the wake is
257
+ * one coalescing flag with one owner, and a second in-process path to it
258
+ * would be a second answer to "is a pass pending".
259
+ */
260
+ wake?: (projectName: string) => Promise<string>;
261
+ }
262
+
263
+ /**
264
+ * The daemon-side view a verb call decides against (#126).
265
+ *
266
+ * `project` is a thunk, not the resolved value the rest of a tick uses. That is
267
+ * the whole "fail closed on an unreadable config" rule: a config read once at
268
+ * boot can never become unreadable, and it cannot pick up an operator who has
269
+ * just taken merge authority back either. Every verb pays one file read for
270
+ * the property that its answer reflects the config as it is *now*.
271
+ *
272
+ * `fleetStop` is read the same way and for the same reason — inside the verb,
273
+ * after the model decided to call it. `hold` and `halt` both set the pause
274
+ * sentinel alongside disarming ticks, so this one read covers both.
275
+ */
276
+ export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbActions">): VerbDeps {
277
+ return {
278
+ project: () => findProject(loadConfig(), d.project.name),
279
+ projects: () => loadConfig().projects,
280
+ store: d.store,
281
+ tracker: d.tracker,
282
+ actions: d.verbActions ?? githubVerbActions(d.project),
283
+ fleetStop: () =>
284
+ isPaused(d.project.name)
285
+ ? "claiming is paused for this fleet (omp-conductor hold or stop)"
286
+ : undefined,
287
+ pausedAt: () => pausedAt(d.project.name),
288
+ log,
289
+ now: () => Date.now(),
290
+ chain: { readBaseChain },
291
+ lane: { probeRunLane },
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Whether the wedged-orchestrator page has gone out, and when it last went out,
297
+ * for the stall currently on disk.
298
+ */
299
+ export interface StallGate {
300
+ paged: boolean;
301
+ lastPagedAt?: number;
302
+ }
303
+
304
+ /**
305
+ * What the daemon booted with, and whether it has already paged about losing
306
+ * it. Lives exactly as long as one `runDaemon()` call — which is the whole
307
+ * trick: a restart re-records both.
308
+ */
309
+ export interface IntegrityGate {
310
+ baseline: Map<string, string>;
311
+ paged: boolean;
312
+ }
313
+
314
+ /**
315
+ * `owner/repo` for `gh`, derived from the clone URL.
316
+ *
317
+ * ponytail: RepoTarget has no explicit slug, so it is parsed off the URL and
318
+ * falls back to the routing name. Upgrade path is an optional `slug` field once
319
+ * a non-GitHub remote actually shows up.
320
+ */
321
+ export function repoSlug(repo: RepoTarget): string {
322
+ const m = /(?:[:/])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(repo.cloneUrl);
323
+ return m?.[1] ?? repo.name;
324
+ }
325
+
326
+ /**
327
+ * The identity the per-repo knowledge overlay is filed under: the canonical
328
+ * `owner/repo` when the clone URL names one, else the routed name.
329
+ *
330
+ * Deliberately NOT {@link repoSlug}: that one is a loose parse whose job is to
331
+ * *say something* in a brief for any URL shape, so a filesystem path yields a
332
+ * plausible-looking pair of directory names. A knowledge file keyed off that
333
+ * would be keyed off where a mirror happens to live. One function, used by both
334
+ * the writer at settlement and the reader in the brief, so the two can never
335
+ * file and read under different names.
336
+ */
337
+ export function knowledgeRepoKey(repo: RepoTarget): string {
338
+ return githubRepo(repo.cloneUrl) ?? repo.name;
339
+ }
340
+
341
+ export type ExtendTurnLimitResult =
342
+ | { kind: "extended"; runId: string; maxTurns: number }
343
+ | { kind: "not-increase"; runId: string; maxTurns: number }
344
+ | { kind: "not-active" };
345
+
346
+ export interface TurnLimitController {
347
+ maxTurns(): number;
348
+ close(): void;
349
+ }
350
+
351
+ export interface TurnLimitRegistry {
352
+ open(project: string, issue: number, runId: string, maxTurns: number): TurnLimitController;
353
+ extend(project: string, issue: number, maxTurns: number): ExtendTurnLimitResult;
354
+ }
355
+
356
+ export type WorkerControlResult =
357
+ | { kind: "ok"; runId: string; phase: WorkerPausePhase }
358
+ | { kind: "stopped"; runId: string; reason: string }
359
+ | { kind: "refused"; runId: string; error: string }
360
+ | { kind: "not-active" };
361
+
362
+ export interface WorkerControlSlot {
363
+ install(control: WorkerPauseControl): void;
364
+ /** Stop accepted before the session controller exists. */
365
+ requestedStop(): string | undefined;
366
+ close(): void;
367
+ }
368
+
369
+ export interface WorkerControlRegistry {
370
+ /**
371
+ * `onPhase` is called after a pause/resume/stop that actually changed this
372
+ * worker's phase (#842) — the authoritative transition, from the same control
373
+ * that performed it. It exists so a surface outside this registry (the run's
374
+ * Herdr representation) can follow the phase without polling and without
375
+ * inventing a second notion of "paused".
376
+ */
377
+ open(
378
+ project: string,
379
+ issue: number,
380
+ runId: string,
381
+ onPhase?: (phase: WorkerPausePhase) => void,
382
+ ): WorkerControlSlot;
383
+ /** `source` names who asked (board/cli/dashboard) — recorded as pause
384
+ * provenance so "who paused this and when" is answerable later (#997). */
385
+ pause(project: string, issue: number, source: string): Promise<WorkerControlResult>;
386
+ resume(project: string, issue: number): WorkerControlResult;
387
+ stop(project: string, issue: number, reason: string): Promise<WorkerControlResult>;
388
+ /** Live runs whose phase is not `running` — what /healthz and the board
389
+ * show — each carrying its pause provenance when one was recorded (#997). */
390
+ snapshot(project: string): {
391
+ issue: number;
392
+ runId: string;
393
+ phase: WorkerPausePhase;
394
+ source?: string;
395
+ pausedAtMs?: number;
396
+ }[];
397
+ }
398
+
399
+ // =============================================== daemon-owned session accounting
400
+ // (Phase 4, attribution.) Grooming and adjudication are real sessions the daemon
401
+ // launches on its own initiative: they resolve a model, take turns, and cost
402
+ // money. Until now their `{turns, spendUsd}` was printed to the journal and
403
+ // thrown away, so the only spend conductor could attribute was worker spend, and
404
+ // every question of the form "what did the pipeline cost, not just the workers"
405
+ // had no answer at all.
406
+ //
407
+ // They are NOT run rows. `computeStats` walks `runs` as issue journeys keyed on
408
+ // `run.issue`, deciding merged/failed/settled from the sequence of states for
409
+ // one issue — a groom row landing in that table would be counted as an attempt
410
+ // on the issue and would corrupt the merge counts it feeds. Hence a dedicated
411
+ // role-tagged table (Lane A's `recordSessionSpend`).
412
+
413
+ /**
414
+ * Persist one daemon-owned session's accounting, telling unmetered from free.
415
+ *
416
+ * The honesty rule this exists to enforce: the harness reports `spendUsd: 0`
417
+ * both for a session that genuinely cost nothing and for one whose provider
418
+ * reports no per-request price at all (a subscription credential). Writing 0 for
419
+ * the second would render as "$0.00" and read as free, which is exactly the
420
+ * mistake `spend-telemetry.ts` was written to stop — "$0.00 spend is not proof
421
+ * of no spend" (#46, #984).
422
+ *
423
+ * So the discrimination is the same one {@link judgeSpendTelemetry} makes: a
424
+ * session that took turns is a *working* session, and a working session
425
+ * reporting zero is UNMETERED — the column is omitted and the stats lane renders
426
+ * it as unknown. A session that took no turns did not run (an unresolvable role,
427
+ * a harness that would not start), and zero is then the true cost.
428
+ *
429
+ * Recorded before any of the caller's verdict branching, so every early return
430
+ * still accounts for the money that was already spent.
431
+ */
432
+ export function recordDaemonSessionSpend(
433
+ d: Pick<Deps, "project" | "store">,
434
+ row: {
435
+ role: "groom" | "adjudicator";
436
+ issue?: number;
437
+ /** The role/model pattern conductor asked for. */
438
+ model?: string;
439
+ /** What actually ran it, when the session reported back. */
440
+ resolvedModel?: string;
441
+ turns: number;
442
+ spendUsd: number;
443
+ at: number;
444
+ },
445
+ ): void {
446
+ const metered = row.turns === 0 || row.spendUsd > 0;
447
+ try {
448
+ d.store.recordSessionSpend({
449
+ project: d.project.name,
450
+ role: row.role,
451
+ ...(row.issue === undefined ? {} : { issue: row.issue }),
452
+ ...(row.model === undefined ? {} : { model: row.model }),
453
+ ...(row.resolvedModel === undefined ? {} : { resolvedModel: row.resolvedModel }),
454
+ turns: row.turns,
455
+ ...(metered ? { spendUsd: row.spendUsd } : {}),
456
+ at: row.at,
457
+ });
458
+ } catch (err) {
459
+ // Accounting must never lose the session it accounts for: the verdict this
460
+ // run produced is worth more than its price tag.
461
+ log(`${row.role} session spend not recorded: ${errText(err)}`);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * The terminal classes that mean "the transcript this round would resume
467
+ * cannot usefully take another turn".
468
+ *
469
+ * Enumerated rather than inferred: each of these is a *cap or a provider
470
+ * refusal*, not a logic failure, so resuming buys a session that dies the same
471
+ * way. The value is the clause the log line and the correction brief read, so
472
+ * the reason a fresh session exists is never "the daemon decided so".
473
+ */
474
+ export const EXHAUSTED_SESSION_CLASSES: Partial<Record<FailureClass, string>> = {
475
+ "turn-cap-progress": "it was killed by the turns cap",
476
+ "turn-cap-spinning": "it was killed by the turns cap while spinning",
477
+ "wall-clock-cap-progress": "it was killed by the wall-clock cap",
478
+ "wall-clock-cap-spinning": "it was killed by the wall-clock cap while spinning",
479
+ "model-empty-stop": "the provider returned empty turns until the harness retry cap ended it",
480
+ };
481
+
482
+ /**
483
+ * Why the session recorded on this run cannot be resumed, or `undefined` when
484
+ * it can (#1047).
485
+ *
486
+ * Two facts, both durable on the row and both readable without opening a
487
+ * transcript:
488
+ *
489
+ * - the row's own terminal classification, when it names a cap or the
490
+ * provider empty-stop; and
491
+ * - the turn ledger: a session at or past its recorded ceiling has no turns
492
+ * left whatever ended it. This is the fact that catches the rounds #1035
493
+ * actually burnt — the review path raises the ceiling by one round's
494
+ * allowance per round (#747), so a round that spends its whole allowance
495
+ * leaves `turns >= maxTurns` on the row even after the claim has cleared
496
+ * the classification.
497
+ *
498
+ * Shared with the answered-block continuation path, which needs the same
499
+ * question answered about the same kind of row: resuming an exhausted session
500
+ * is exactly as useless there.
501
+ */
502
+ export function exhaustedSessionReason(
503
+ run: Pick<RunRecord, "failureClass" | "turns" | "maxTurns">,
504
+ ): string | undefined {
505
+ const classed = run.failureClass === undefined ? undefined : EXHAUSTED_SESSION_CLASSES[run.failureClass];
506
+ if (classed !== undefined) return `${classed} (${run.failureClass})`;
507
+ if (run.turns >= run.maxTurns) {
508
+ return `it has spent its whole turn ceiling (${run.turns}/${run.maxTurns} turns)`;
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ /** Canonical `owner/repo` identity from a configured network clone URL. */
514
+ export function githubRepo(cloneUrl: string): string | undefined {
515
+ const normalized = cloneUrl.replace(/\/$/, "").replace(/\.git$/, "");
516
+ const match = /^(?:https?:\/\/[^/]+\/|ssh:\/\/git@[^/]+\/|git@[^:]+:)([^/\s]+\/[^/\s]+)$/.exec(
517
+ normalized,
518
+ );
519
+ return match?.[1];
520
+ }
521
+
522
+ export interface RetainedCleanupCursor {
523
+ next: number;
524
+ }
525
+
526
+ /** A provider refusal a session recorded before dying, or undefined for none. */
527
+ export interface SessionError {
528
+ status?: number;
529
+ message: string;
530
+ }
531
+
532
+ export function completionLastError(
533
+ providerCredit: string | undefined,
534
+ providerTransient: string | undefined,
535
+ verifiedReason: string | undefined,
536
+ sessionErr: SessionError | undefined,
537
+ ): string | undefined {
538
+ return providerCredit ?? providerTransient ?? verifiedReason ?? sessionErr?.message;
539
+ }