omp-conductor 0.19.7 → 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.
Files changed (69) 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/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -574,6 +574,62 @@ function orDash(value, format) {
574
574
  return value === null || value === undefined ? "—" : format(value);
575
575
  }
576
576
 
577
+ /**
578
+ * The attribution block (phase 4): where the spend went, as opposed to what it
579
+ * bought. Rendered in the empty case too — a dry queue is exactly when the
580
+ * daemon's own grooming sessions run, and that spend bought no outcome, which
581
+ * is the number an operator most wants to see.
582
+ */
583
+ function renderAttribution(out, report) {
584
+ const models = report.models ?? [];
585
+ if (models.length > 0) {
586
+ const box = el("div", "chart");
587
+ box.appendChild(el("h4", undefined, "Per model (grouped on the model actually billed)"));
588
+ const rows = el("div", "summary");
589
+ for (const m of models) {
590
+ // Nothing metered at all is stated as unknown: "$0.00 metered" is true and
591
+ // still reads as free, which is the misreading this panel exists to
592
+ // prevent — the same rule the CLI's human form follows.
593
+ const cost =
594
+ m.spendUsd === 0 && m.unmeteredRuns === m.runs
595
+ ? "cost unknown — no run on this model metered"
596
+ : `${usd(m.spendUsd)} metered · ${orDash(m.spendPerMerge, usd)} / merge` +
597
+ (m.unmeteredRuns > 0 ? ` · ${m.unmeteredRuns} unmetered` : "");
598
+ rows.appendChild(statLine(m.model, `${m.runs} run(s) · ${m.merges} merge(s) · ${cost}`));
599
+ }
600
+ box.appendChild(rows);
601
+ out.appendChild(box);
602
+ }
603
+
604
+ const ran = (report.sessions ?? []).filter((s) => s.sessions > 0);
605
+ const unmeteredRoles = report.unmeteredRoles ?? [];
606
+ // Silence rather than a row of zeros: "no grooming ran" and "grooming cost
607
+ // nothing" are different claims, and only the first would be true.
608
+ if (ran.length === 0 && unmeteredRoles.length === 0) return;
609
+ const box = el("div", "chart");
610
+ box.appendChild(el("h4", undefined, "Daemon-owned sessions (not worker runs)"));
611
+ const rows = el("div", "summary");
612
+ for (const s of ran) {
613
+ // An unmetered session's cost is unknown, so it is stated as a count and
614
+ // never folded into the dollar figure as a zero.
615
+ const cost =
616
+ s.spendUsd === 0 && s.unmeteredSessions === s.sessions
617
+ ? "cost unknown — no session metered"
618
+ : `${usd(s.spendUsd)} metered` +
619
+ (s.unmeteredSessions > 0
620
+ ? ` · ${s.unmeteredSessions} unmetered (cost unknown, not $0.00)`
621
+ : "");
622
+ rows.appendChild(statLine(s.role, `${s.sessions} session(s) · ${s.turns} turn(s) · ${cost}`));
623
+ }
624
+ for (const role of unmeteredRoles) {
625
+ rows.appendChild(
626
+ statLine(role, "unmetered — no usage events observable, so the cost is unknown"),
627
+ );
628
+ }
629
+ box.appendChild(rows);
630
+ out.appendChild(box);
631
+ }
632
+
577
633
  function hours(ms) {
578
634
  return `${(ms / 3_600_000).toFixed(1)}h`;
579
635
  }
@@ -594,6 +650,7 @@ async function refreshStats() {
594
650
  ),
595
651
  );
596
652
  out.appendChild(statLine("GitHub API calls", String(report.ghCalls)));
653
+ renderAttribution(out, report);
597
654
  return;
598
655
  }
599
656
 
@@ -657,6 +714,7 @@ async function refreshStats() {
657
714
  (v) => String(v),
658
715
  ),
659
716
  );
717
+ renderAttribution(out, report);
660
718
  }
661
719
 
662
720
  statsWindowBar.addEventListener("click", (event) => {
@@ -47,6 +47,7 @@ import { makeTracker } from "../tracker/github.ts";
47
47
  import { dbPath, openStore } from "../store.ts";
48
48
  import { loadConfig, resolveCaps } from "../config.ts";
49
49
  import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts";
50
+ import { httpAuthHeader, missingHttpTokenMessage } from "../http-token.ts";
50
51
  import type { ProjectConfig } from "../types.ts";
51
52
 
52
53
  /** The source string every dashboard mutation attributes itself with. */
@@ -265,8 +266,24 @@ export function dashboardAnswerDecision(id: string, body: unknown, d: ControlDep
265
266
 
266
267
  // ------------------------------------------------------- production wiring --
267
268
 
268
- /** Forward to the living daemon's own HTTP surface, or report there is none. */
269
- async function defaultProxy(
269
+ /**
270
+ * Forward to the living daemon's own HTTP surface, or report there is none.
271
+ *
272
+ * Every route this forwards to is mutating, so the request carries the
273
+ * daemon's bearer token (Phase 4). Note this is deliberately *not* the
274
+ * dashboard's own token: the browser authenticated to the dashboard, and the
275
+ * dashboard authenticates to the daemon as a second, separately-scoped hop.
276
+ * A missing daemon token is its own answer rather than the 502 "no living
277
+ * daemon" — the daemon may be perfectly alive; what is missing is the
278
+ * credential file, and telling the operator "start the daemon" when it is
279
+ * already running would send them chasing the wrong thing.
280
+ *
281
+ * Exported so the suite can drive the *production* forwarder — the one that
282
+ * actually builds the request — against a stub daemon. Every other test injects
283
+ * `ControlDeps.proxy`, which by construction cannot prove this function sends
284
+ * the credential.
285
+ */
286
+ export async function defaultProxy(
270
287
  project: ProjectConfig,
271
288
  path: string,
272
289
  body: unknown,
@@ -277,13 +294,15 @@ async function defaultProxy(
277
294
  // would apply an operator's action to the wrong fleet.
278
295
  if (record === undefined) return undefined;
279
296
  if (record.project !== undefined && record.project !== project.name) return undefined;
297
+ const auth = httpAuthHeader();
298
+ if (auth === undefined) return { status: 503, body: { error: missingHttpTokenMessage() } };
280
299
  // Cheap liveness first, so an unreachable daemon is a 502 rather than a
281
300
  // mutation attempt that hangs the browser.
282
301
  if (!(await healthCheck(record.port)).ok) return undefined;
283
302
  try {
284
303
  const res = await fetch(`http://127.0.0.1:${record.port}${path}`, {
285
304
  method: "PUT",
286
- headers: { "content-type": "application/json" },
305
+ headers: { "content-type": "application/json", ...auth },
287
306
  body: JSON.stringify(body),
288
307
  signal: AbortSignal.timeout(10_000),
289
308
  });
@@ -331,6 +331,10 @@ async function statsProducer(name: string, since: string): Promise<StatsReport |
331
331
  window,
332
332
  ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
333
333
  runs: store.statsRuns(name, window.sinceEpochMs),
334
+ // Phase 4 attribution: daemon-owned session spend lives in its own table,
335
+ // deliberately outside `runs`, so it is a second read rather than more
336
+ // rows in the first one.
337
+ sessions: store.sessionSpendSince(name, window.sinceEpochMs),
334
338
  });
335
339
  } finally {
336
340
  store.close();
package/src/diff-flags.ts CHANGED
@@ -813,14 +813,35 @@ function claimRegion(body: string): string {
813
813
  return next === null ? rest : rest.slice(0, marker[0].length + next.index);
814
814
  }
815
815
 
816
+ /**
817
+ * An executable token: the shape a claimed command's first word must have to
818
+ * be a program somebody could have run. Either an ordinary command name — an
819
+ * ASCII letter or `_` first, then letters, digits, `_`, `.`, `+`, `-` (`bun`,
820
+ * `bash`, `cd`, `export`, `omp-conductor`, `python3.12`) — or a `./`-relative
821
+ * executable path built from those same characters plus `/`
822
+ * (`./scripts/deploy.sh`).
823
+ *
824
+ * Nothing else is a command, and that exclusion is the whole point (#1039):
825
+ * this predicate used to accept any first word that merely lacked a `/`, so
826
+ * the malformed-JSON fixture `{not json` described in a PR's verified prose
827
+ * became a "claimed" command with no transcript match, and a green PR settled
828
+ * with a false `claimed-proof-missing`. The grammar therefore refuses
829
+ * punctuation-led fragments (`{not`, `{"kind":`, `[1,`), quote-led values
830
+ * (`"some value"`), tokens carrying `:` or `=` (config expressions like
831
+ * `retry.modelFallback: true`, env assignments), digit-led data literals
832
+ * (timestamps like `2026-08-24 09:14Z`) and flag-led spans (`--json`). An
833
+ * absolute path stays excluded as it always was: it carries `/` without the
834
+ * leading `./`.
835
+ */
836
+ const EXECUTABLE_TOKEN = /^(?:[A-Za-z_][A-Za-z0-9_.+-]*|\.\/[A-Za-z0-9_.+\-/]+)$/;
837
+
816
838
  /** One backticked span that reads as a whole command: at least two shell words
817
- * and a first word that is an executable (no path separator, or a `./` one).
839
+ * and a first word that is an {@link EXECUTABLE_TOKEN}.
818
840
  * A span that names a single file or a config key is not a command claim. */
819
841
  function claimedCommand(span: string): boolean {
820
842
  const words = span.split(/\s+/).filter((word) => word.length > 0);
821
843
  if (words.length < 2) return false;
822
- const first = words[0] ?? "";
823
- return first.startsWith("./") || !first.includes("/");
844
+ return EXECUTABLE_TOKEN.test(words[0] ?? "");
824
845
  }
825
846
 
826
847
  /**
@@ -62,6 +62,16 @@ const INFRA_CHECK_STATES: Record<string, true> = {
62
62
  /** States that mean "this check has a verdict and it is good". */
63
63
  const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
64
64
 
65
+ /**
66
+ * The runner's own sentence for "the agent under this job went away": written
67
+ * when the runner service is stopped, auto-updates, or is torn down under a
68
+ * live job. Named once because two different accountings quote it — the
69
+ * implementation-attempt waiver below ({@link INFRA_LOG_SIGNATURES}, #177) and
70
+ * the review-correction waiver ({@link runnerInfraFailure}) — and a second
71
+ * spelling of it would waive one while charging the other.
72
+ */
73
+ const RUNNER_SHUTDOWN_SIGNAL = "the runner has received a shutdown signal";
74
+
65
75
  /**
66
76
  * Substrings in a failed check's log that prove the failure was infrastructure,
67
77
  * not the diff (#177). Each is a registry/docker/runner fault a worker cannot
@@ -87,7 +97,7 @@ const INFRA_LOG_SIGNATURES = [
87
97
  // the runner's, and must not be waived (#637, #639).
88
98
  "response status code does not indicate success: 429 (too many requests)",
89
99
  "failed to resolve source metadata for",
90
- "the runner has received a shutdown signal",
100
+ RUNNER_SHUTDOWN_SIGNAL,
91
101
  "could not resolve host",
92
102
  ];
93
103
 
@@ -103,6 +113,70 @@ export function infraLogSignature(log: string): string | undefined {
103
113
  return INFRA_LOG_SIGNATURES.find((signature) => lower.includes(signature));
104
114
  }
105
115
 
116
+ /**
117
+ * Runner-infrastructure wordings: a red check whose own text says the *runner*
118
+ * failed, not the diff (Phase 3, #1043 lane). Matched lowercased as substrings
119
+ * against the failing job's log — the same surface {@link infraLogSignature}
120
+ * reads — and each entry is the real GitHub Actions sentence, verified against
121
+ * the runner's own reports rather than invented:
122
+ *
123
+ * Kept as its own list rather than folded into {@link INFRA_LOG_SIGNATURES}
124
+ * for two reasons. The consumers differ: that list waives an *implementation
125
+ * attempt* for a settled run (#177), this one waives a *correction round* for
126
+ * a live pull request's red check. And {@link infraSignatureVersion} is a
127
+ * persisted cursor fingerprint — growing that list restarts the historical
128
+ * reconciliation, which recognising a runner-lost red has no business doing.
129
+ * The one overlap, {@link RUNNER_SHUTDOWN_SIGNAL}, is shared by reference so
130
+ * the two accountings can never disagree about that wording.
131
+ */
132
+ const RUNNER_INFRA_SIGNATURES = [
133
+ // "The self-hosted runner: <name> lost communication with the server." and
134
+ // its hosted sibling "The hosted runner: <name> lost communication with the
135
+ // server." — the runner process was killed, starved or cut off mid-job
136
+ // (actions/runner#3539, community#84877, community#173431). The name varies,
137
+ // so only the invariant tail is matched.
138
+ "lost communication with the server",
139
+ // The runner service was stopped or auto-updated under the job: an
140
+ // infrastructure cancellation, not a verdict on the diff.
141
+ RUNNER_SHUTDOWN_SIGNAL,
142
+ // "The job was not acquired by Runner of type hosted even after multiple
143
+ // attempts" — the job never reached a runner at all, so nothing in the diff
144
+ // was ever executed (community#186216, community#165287).
145
+ "was not acquired by runner",
146
+ // "The hosted runner encountered an error while running your job. (Error
147
+ // Type: Failure)." — GitHub's own statement that its runner broke
148
+ // (community#126539). The parenthesised error type varies; the sentence does
149
+ // not.
150
+ "the hosted runner encountered an error while running your job",
151
+ ];
152
+
153
+ /**
154
+ * The runner-infrastructure wording a red CI check's text carries, or
155
+ * `undefined` when it carries none.
156
+ *
157
+ * Exists so the review-correction accounting can decline to charge a
158
+ * correction round for a red that the worker's diff did not cause: a lost
159
+ * runner, a runner shut down under the job, or a job no runner ever picked up.
160
+ * The return value is the matched wording, so whatever waives a round can say
161
+ * *which* sentence waived it instead of asserting "infra" unexplained.
162
+ *
163
+ * Fails closed in both directions, and deliberately narrower than it could be:
164
+ *
165
+ * - Unclassifiable, empty or unread text is `undefined` — an unexplained red
166
+ * still counts, and a log-fetch failure hands this function nothing to match,
167
+ * so it never waives.
168
+ * - "The operation was canceled." is *not* here: it is what a failed `needs:`
169
+ * dependency and an operator's own cancel both print, so matching it would
170
+ * waive rounds for ordinary red.
171
+ * - "…has exceeded the maximum execution time of N minutes" is *not* here
172
+ * either: a job that ran until the ceiling is usually a hanging test or an
173
+ * infinite loop, which is exactly the diff's problem to fix.
174
+ */
175
+ export function runnerInfraFailure(log: string): string | undefined {
176
+ const lower = log.toLowerCase();
177
+ return RUNNER_INFRA_SIGNATURES.find((signature) => lower.includes(signature));
178
+ }
179
+
106
180
  /**
107
181
  * The cap kills that produced nothing: the worker reached a ceiling with no PR,
108
182
  * no observed head and no salvage commit.