omp-conductor 0.15.11 → 0.15.13

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 (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -58,7 +58,7 @@ export interface ReleaseBlockContext {
58
58
  export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
59
59
  project: string;
60
60
  source: "worker" | "orchestrator";
61
- shape: ReleaseShape;
61
+ shape: GateShape;
62
62
  at: string;
63
63
  issue?: number;
64
64
  runId?: string;
@@ -66,37 +66,258 @@ export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
66
66
 
67
67
  export type ReleaseDecision = { block: true; reason: string };
68
68
 
69
+ /**
70
+ * The shared-host heavy-gate shape (#428): whole-package test runs and the
71
+ * shell suites a worker must be *unable* to run on the operator's shared host,
72
+ * rather than merely told not to.
73
+ *
74
+ * Deliberately **not** a {@link ReleaseShape}: no grant covers it. An operator
75
+ * is never refused — the operator owns the host, and CI owns the full suite —
76
+ * and a worker is always, whatever the config says. So it stays out of the
77
+ * configurable grant vocabulary (`releaseShapeEnum` is drawn straight from
78
+ * {@link RELEASE_SHAPES}); it only rides the release-policy classifier and the
79
+ * block ledger so one seam sees it. Its refusals land in the same ledger the
80
+ * tick reads, but the tick reports them apart from release drift (#562):
81
+ * a refusal here is the interlock working, never a release-policy violation
82
+ * and never something a grant could have permitted.
83
+ */
84
+ export const SHARED_HOST_SHAPE = "shared-host-gate" as const;
85
+ export type SharedHostShape = typeof SHARED_HOST_SHAPE;
86
+
87
+ /** Every shape the classifier reports, grantable or not. */
88
+ export type GateShape = ReleaseShape | SharedHostShape;
89
+
69
90
  const GIT_PUSH_TAG_SHAPE =
70
91
  /(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
71
92
 
93
+ /** The separators the original quote-blind splitter divides a command on. Kept
94
+ * for the unterminated-quote fallback, which must reproduce today's behaviour
95
+ * rather than silently pass a command through. */
96
+ const COMMAND_SEPARATORS = /(?:&&|\|\||[;\n|])/;
97
+
98
+ /**
99
+ * Split a shell command on its operators, ignoring ones inside single or
100
+ * double quotes. The old splitter was quote-blind, so a `|` inside a quoted
101
+ * argument (a grep alternation's `\|`, a `--jq` filter, a heredoc) shredded
102
+ * the command into phantom segments and a gated verb inside a string literal
103
+ * was refused as a release attempt (#526). A small scanner is enough: no shell
104
+ * parser, no dependency. An unterminated quote falls back to the original
105
+ * quote-blind split, because a gated command must not slip through merely
106
+ * because a quote never closed.
107
+ */
108
+ function splitCommandSegments(command: string): string[] {
109
+ const segments: string[] = [];
110
+ let current = "";
111
+ let quote: "'" | '"' | null = null;
112
+
113
+ const push = () => {
114
+ segments.push(current);
115
+ current = "";
116
+ };
117
+
118
+ for (let i = 0; i < command.length; i++) {
119
+ const ch = command[i];
120
+
121
+ // Inside a quote only its closing quote matters — separators are data. A
122
+ // `\"` inside double quotes is a literal quote and must not close it.
123
+ if (quote !== null) {
124
+ current += ch;
125
+ if (ch === "\\" && quote === '"') {
126
+ if (i + 1 < command.length) current += command[++i];
127
+ continue;
128
+ }
129
+ if (ch === quote) quote = null;
130
+ continue;
131
+ }
132
+
133
+ // Outside quotes a backslash escapes the next character, so a literal
134
+ // `\|`, `\;` or `\"` is data, never an operator or the start of a quote.
135
+ if (ch === "\\") {
136
+ current += ch;
137
+ if (i + 1 < command.length) current += command[++i];
138
+ continue;
139
+ }
140
+
141
+ if (ch === "'") {
142
+ quote = "'";
143
+ current += ch;
144
+ continue;
145
+ }
146
+ if (ch === '"') {
147
+ quote = '"';
148
+ current += ch;
149
+ continue;
150
+ }
151
+
152
+ if ((ch === "&" && command[i + 1] === "&") || (ch === "|" && command[i + 1] === "|")) {
153
+ push();
154
+ i++;
155
+ continue;
156
+ }
157
+ if (ch === "|" || ch === ";" || ch === "\n") {
158
+ push();
159
+ continue;
160
+ }
161
+
162
+ current += ch;
163
+ }
164
+
165
+ if (quote !== null) return command.split(COMMAND_SEPARATORS);
166
+
167
+ push();
168
+ return segments;
169
+ }
170
+
72
171
  function commandSegments(command: string): string[] {
73
- return command
74
- .split(/(?:&&|\|\||[;\n|])/)
172
+ return splitCommandSegments(command)
75
173
  .map((segment) => segment.trim())
76
174
  .filter((segment) => segment.length > 0);
77
175
  }
78
176
 
177
+ /** A leading wrapper command, matched and consumed in a chain (#558). `env`
178
+ * was already stripped; `timeout`, `nice`, `stdbuf` and a shell `-c` were
179
+ * not, so a whole-package run became allowed the moment it picked up a
180
+ * wrapper — the incident ran `timeout 300 bun test` and the guard never
181
+ * fired. Each alternative owns the arguments that belong to it (the
182
+ * duration, the niceness, the option run), so stripping never swallows the
183
+ * command's own words. Ordered longest-first so `timeout` is consumed
184
+ * before `time` and `env` before a bare `VAR=…` chain. */
185
+ const COMMAND_WRAPPER_PREFIX = new RegExp(
186
+ [
187
+ "^sudo(?:\\s+-[a-z][a-z0-9-]*)*\\s+",
188
+ "^env\\s+",
189
+ "^(?:[A-Za-z_][A-Za-z0-9_]*=\\S+\\s+)+",
190
+ "^timeout(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+(?:inf|infinity|\\d+(?:\\.\\d+)?[smhd]?)\\s+",
191
+ "^nice(?:\\s+-n\\s+-?\\d+|\\s+-\\d+|\\s+--adjustment\\s*=\\s*-?\\d+)?\\s+",
192
+ "^ionice(?:\\s+--?[a-z][a-z0-9-]*(?:\\s+\\d+)?)*\\s+",
193
+ "^stdbuf(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|[A-Za-z0-9]+|\\s+[A-Za-z0-9])?)*\\s+",
194
+ "^xargs(?:\\s+--?[A-Za-z][A-Za-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+",
195
+ "^time\\s+",
196
+ ].join("|"),
197
+ );
198
+
199
+ /** `bash -c`, `sh -c`, `zsh -c` with optional preceding flags — `-lc` and
200
+ * friends parse as combined short options, so the `c` may ride in the same
201
+ * token as the flags before it. A `bash -n` parse check is deliberately not
202
+ * one: it executes nothing. */
203
+ const SHELL_DASH_C = /^(?:bash|sh|zsh)\s+(?:-\S+\s+)*?-[A-Za-z]*c\b\s+/;
204
+
205
+ /**
206
+ * The command a `bash -c '<cmd>'` / `sh -c "<cmd>"` segment executes is the
207
+ * quoted string, not the `bash` invocation, so a wrapper command that reaches
208
+ * for a shell must be resolved to the string inside the quotes before
209
+ * classifying. Extra `$0`.. arguments after the closing quote are ignored:
210
+ * they are not the command.
211
+ */
212
+ function shellCommandInner(segment: string): string | undefined {
213
+ const shell = SHELL_DASH_C.exec(segment);
214
+ if (shell === null) return undefined;
215
+ const rest = segment.slice(shell[0].length);
216
+ const quote = rest[0];
217
+ if (quote !== "'" && quote !== '"') return undefined;
218
+ const close = rest.indexOf(quote, 1);
219
+ if (close < 0) return undefined;
220
+ return rest.slice(1, close);
221
+ }
222
+
223
+ /** Normalise a segment before classification: consume every leading wrapper
224
+ * command (#558). The old stripper recognised `env` and `sudo` and nothing
225
+ * else, so a whole-package `bun test` became allowed the moment a worker
226
+ * wrapped it in `timeout`/`nice`/`bash -c`. Chains matter — `timeout 300
227
+ * nice bun test` is one command with two wrappers — so the table is matched
228
+ * repeatedly until the leading word belongs to the command itself.
229
+ */
79
230
  function stripCommandPrefix(segment: string): string {
80
- return segment
81
- .replace(/^env\s+/, "")
82
- .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*/, "")
83
- .replace(/^sudo\s+/, "");
231
+ let stripped = segment;
232
+ let match: RegExpExecArray | null;
233
+ while ((match = COMMAND_WRAPPER_PREFIX.exec(stripped)) !== null) {
234
+ stripped = stripped.slice(match[0].length);
235
+ }
236
+ return stripped;
84
237
  }
85
238
 
86
239
  /** Recognise the explicit release/deploy command shapes this policy promises to gate. */
87
- export function releaseShapeFromCommand(command: string): ReleaseShape | undefined {
240
+ export function releaseShapeFromCommand(command: string): GateShape | undefined {
88
241
  return releaseCommandMatch(command)?.shape;
89
242
  }
90
243
 
244
+ /**
245
+ * A whole-package `bun test`: `bun test` with no positional naming a specific
246
+ * test file. Flags alone — or nothing at all — still run the whole project,
247
+ * which is the gate (#428), and so do the path spellings of "everything":
248
+ * `bun test .`, `bun test ./`, `bun test src`. A command whose positional
249
+ * targets are test files — basename carrying the `*.test.*` / `*_test.*`
250
+ * marker bun itself keys on — is a focused run, a worker's proof path, and
251
+ * must stay available. A flag's own value is not a focused target: a
252
+ * `--preload x.ts` run with no file runs the whole package with a preload,
253
+ * while `--preload ./setup.ts src/daemon.test.ts` is still a test-file run.
254
+ */
255
+ function wholePackageBunTest(segment: string): boolean {
256
+ const head = /^bun\s+test\b/.exec(segment);
257
+ if (head === null) return false;
258
+ const rest = segment.slice(head[0].length);
259
+ return !rest
260
+ .split(/\s+/)
261
+ .filter((token) => token.length > 0)
262
+ .filter((token) => !token.startsWith("-"))
263
+ .some((token) => /\.test\.|_test\./.test(token.slice(token.lastIndexOf("/") + 1)));
264
+ }
265
+
266
+ /**
267
+ * The shared-host run/shell suites a worker must not execute on the operator's
268
+ * host (#428): `herdr/test/recover-test.sh`, `test/setup-test.sh` and
269
+ * `setup.sh`, each under the invocation prefixes a worker actually uses —
270
+ * plain `bash`/`sh`/`zsh`, POSIX `. ` and `source` (all so the script loads),
271
+ * and a bare or `./` path. Returns the script path that fired, for the
272
+ * matched-segment ledger record.
273
+ *
274
+ * `bash -n` is deliberately not a match: parsing a script never executes it,
275
+ * and the pre-push parse gate every worker brief runs is
276
+ * `bash -n herdr/bin/recover.sh herdr/test/recover-test.sh setup.sh
277
+ * test/setup-test.sh` — blocking that would make the gate unfollowable without
278
+ * buying a single byte of protection. A flag beyond `-n` (`bash -x`, `-e`) is
279
+ * a real execution and is not covered either: the incidents that overloaded
280
+ * the host were all plain invocations, and refusing the sanctioned parse check
281
+ * would teach workers to skip it rather than stop the load.
282
+ */
283
+ const SHARED_HOST_SCRIPTS = [
284
+ "herdr/test/recover-test.sh",
285
+ "test/setup-test.sh",
286
+ "setup.sh",
287
+ ];
288
+
289
+ const SHARED_HOST_PREFIX = /^(?:(?:bash|sh|zsh|source)\s+|\.\s+)?\.?\/?/;
290
+
291
+ function sharedHostScriptMatch(segment: string): string | undefined {
292
+ const path = segment.replace(SHARED_HOST_PREFIX, "");
293
+ for (const script of SHARED_HOST_SCRIPTS) {
294
+ if (path === script || path.startsWith(`${script} `) || path.startsWith(`${script}\t`)) {
295
+ return script;
296
+ }
297
+ }
298
+ return undefined;
299
+ }
300
+
91
301
  /**
92
302
  * Classify a shell command and the segment that fired. The segment is what a
93
303
  * triager reads: `bun test src/foo.test.ts && npm publish` matches on its
94
304
  * `npm publish` half, and the record says so instead of folding the whole
95
305
  * command into the shape.
96
306
  */
97
- function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: string } | undefined {
307
+ function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
98
308
  for (const raw of commandSegments(command)) {
99
309
  const segment = stripCommandPrefix(raw);
310
+ // A `bash -c '<cmd>'` / `sh -c "<cmd>"` wrapper executes the quoted
311
+ // string, so classify that string as a command of its own: a whole
312
+ // package inside the quotes is refused (#558), a focused run inside
313
+ // the quotes stays a focused run, and a harmless `-c` script body
314
+ // (the pre-push `bash -n` parse gate) never unwraps to begin with.
315
+ const inner = shellCommandInner(segment);
316
+ if (inner !== undefined) {
317
+ const nested = releaseCommandMatch(inner);
318
+ if (nested !== undefined) return nested;
319
+ continue;
320
+ }
100
321
  // Patching the running conductor is gated like a release act: `upgrade`,
101
322
  // the detached `upgrade-install`/`upgrade-rollback` executor entries and
102
323
  // any future spelling under the upgrade family all need the install shape,
@@ -121,6 +342,13 @@ function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: s
121
342
  ) {
122
343
  return { shape: "deploy", matched: segment };
123
344
  }
345
+ // The shared-host gate (#428). Disjoint from every release shape above, so
346
+ // it is checked last without disturbing any of them. `matched` names the
347
+ // script path for a suite and the offending segment for a whole-package
348
+ // `bun test`, so the ledger and the digest say exactly which one fired.
349
+ if (wholePackageBunTest(segment)) return { shape: SHARED_HOST_SHAPE, matched: segment };
350
+ const script = sharedHostScriptMatch(segment);
351
+ if (script !== undefined) return { shape: SHARED_HOST_SHAPE, matched: script };
124
352
  }
125
353
  return undefined;
126
354
  }
@@ -256,7 +484,7 @@ function releaseTokenMatch(
256
484
  export function releaseToolMatch(
257
485
  toolName: string,
258
486
  input: Record<string, unknown> | undefined,
259
- ): { shape: ReleaseShape; matched: string } | undefined {
487
+ ): { shape: GateShape; matched: string } | undefined {
260
488
  if (input === undefined) return undefined;
261
489
  if (toolName === "bash") {
262
490
  return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined;
@@ -278,10 +506,31 @@ export function releaseToolMatch(
278
506
  export function releaseShapeFromTool(
279
507
  toolName: string,
280
508
  input: Record<string, unknown> | undefined,
281
- ): ReleaseShape | undefined {
509
+ ): GateShape | undefined {
282
510
  return releaseToolMatch(toolName, input)?.shape;
283
511
  }
284
512
 
513
+ /**
514
+ * The refusal wording for the shared-host gate (#428). The whole-package
515
+ * `bun test` form names the focused alternative (a guard that only denies
516
+ * teaches nothing and gets worked around); the shell suites get their own
517
+ * because there is no focused form of a suite script.
518
+ */
519
+ function sharedHostRefusalReason(matched: string | undefined): string {
520
+ if (matched !== undefined && /^bun\s+test\b/.test(matched)) {
521
+ return (
522
+ "Blocked by sharedHostPolicy: the whole-package `bun test` is not a worker's proof path on this shared " +
523
+ "host (it overloads the 4-core VPS and, before #399, SIGTERMed the production daemon). " +
524
+ "Run a focused `bun test <file>.test.ts` instead."
525
+ );
526
+ }
527
+ return (
528
+ "Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " +
529
+ "(it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
530
+ "Run focused `bun test <file>.test.ts` unit tests instead."
531
+ );
532
+ }
533
+
285
534
  /**
286
535
  * The refusal a `role` session gets for `shape` under `grants`, or `undefined`
287
536
  * when the grant covers it.
@@ -296,8 +545,16 @@ export function releaseShapeFromTool(
296
545
  export function releaseRefusal(
297
546
  grants: ResolvedGrants,
298
547
  role: SessionRole,
299
- shape: ReleaseShape,
548
+ shape: GateShape,
300
549
  ): ReleaseDecision | undefined {
550
+ // The shared-host gate is not a grant anyone holds: a worker is always
551
+ // refused — the shared operator host cannot absorb a whole-package suite,
552
+ // which is the exact load that stopped it — and an operator session is
553
+ // never, because the operator owns the host and CI owns the full suite.
554
+ if (shape === SHARED_HOST_SHAPE) {
555
+ if (role !== "worker") return undefined;
556
+ return { block: true, reason: sharedHostRefusalReason(undefined) };
557
+ }
301
558
  const holder = grants[shape];
302
559
  if (holder === role) return undefined;
303
560
  return {
@@ -319,11 +576,22 @@ export function releaseDecision(
319
576
  role: SessionRole,
320
577
  toolName: string,
321
578
  input: Record<string, unknown>,
322
- ): { shape: ReleaseShape; matched: string; decision: ReleaseDecision } | undefined {
579
+ ): { shape: GateShape; matched: string; decision: ReleaseDecision } | undefined {
323
580
  const match = releaseToolMatch(toolName, input);
324
581
  if (match === undefined) return undefined;
325
582
  const decision = releaseRefusal(grants, role, match.shape);
326
- return decision === undefined ? undefined : { shape: match.shape, matched: match.matched, decision };
583
+ if (decision === undefined) return undefined;
584
+ // The shared-host gate's wording is matched-specific (name the focused form
585
+ // for a whole-package `bun test`, name the suite for a script), so it is
586
+ // read off the offending segment here rather than from `releaseRefusal`.
587
+ if (match.shape === SHARED_HOST_SHAPE) {
588
+ return {
589
+ shape: match.shape,
590
+ matched: match.matched,
591
+ decision: { block: true, reason: sharedHostRefusalReason(match.matched) },
592
+ };
593
+ }
594
+ return { shape: match.shape, matched: match.matched, decision };
327
595
  }
328
596
 
329
597
  const RELEASE_ARG_ALLOWLIST = new Set([
@@ -424,7 +692,7 @@ interface ReleasePolicyPi {
424
692
  export function releasePolicyTripwire(
425
693
  grants: ResolvedGrants,
426
694
  role: SessionRole,
427
- onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
695
+ onBlocked: (shape: GateShape, context: ReleaseBlockContext) => void = () => {},
428
696
  ): (pi: ReleasePolicyPi) => void {
429
697
  return (pi) => {
430
698
  pi.on("tool_call", (event) => {
@@ -450,7 +718,7 @@ export function releasePolicyTripwire(
450
718
  export function recordReleaseBlock(
451
719
  project: string,
452
720
  source: ReleaseBlock["source"],
453
- shape: ReleaseShape,
721
+ shape: GateShape,
454
722
  details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
455
723
  root = stateDir(),
456
724
  now = new Date(),
@@ -477,21 +745,21 @@ export interface ReleaseDriftSummary {
477
745
  latest: ReleaseBlock;
478
746
  }
479
747
 
480
- /** Aggregate today's blocked attempts for the orchestrator's daily digest. */
481
- export function releaseDriftToday(
482
- project: string,
483
- root = stateDir(),
484
- now = new Date(),
485
- ): ReleaseDriftSummary | undefined {
748
+ /**
749
+ * The `release-policy-blocks.jsonl` rows recorded for `project` on `now`'s
750
+ * UTC day, in file order. One scan backs both day summaries, so the release
751
+ * drift counter and the shared-host counter can never disagree about which
752
+ * ledger rows are in play (#562).
753
+ */
754
+ function auditRowsToday(project: string, root: string, now: Date): ReleaseBlock[] {
486
755
  let text: string;
487
756
  try {
488
757
  text = readFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), "utf8");
489
758
  } catch {
490
- return undefined;
759
+ return [];
491
760
  }
492
761
  const day = now.toISOString().slice(0, 10);
493
- let count = 0;
494
- let latest: ReleaseBlock | undefined;
762
+ const rows: ReleaseBlock[] = [];
495
763
  for (const line of text.split("\n")) {
496
764
  if (line.length === 0) continue;
497
765
  try {
@@ -503,14 +771,58 @@ export function releaseDriftToday(
503
771
  (value.source === "worker" || value.source === "orchestrator") &&
504
772
  typeof value.shape === "string"
505
773
  ) {
506
- count += 1;
507
- latest = value as ReleaseBlock;
774
+ rows.push(value as ReleaseBlock);
508
775
  }
509
776
  } catch {
510
777
  // One torn line does not hide later valid audit records.
511
778
  }
512
779
  }
513
- return latest === undefined ? undefined : { count, latest };
780
+ return rows;
781
+ }
782
+
783
+ /**
784
+ * Aggregate today's blocked release/deploy attempts for the orchestrator's
785
+ * daily digest. The shared-host gate (#428) is deliberately excluded (#562):
786
+ * a guard refusal is the interlock working, never a divergence from release
787
+ * policy, and no grant could ever have permitted the command — so counting a
788
+ * refusal here would report a success as a violation. {@link
789
+ * sharedHostRefusalsToday} reads the same ledger and reports those rows on
790
+ * their own line, so nothing is dropped.
791
+ */
792
+ export function releaseDriftToday(
793
+ project: string,
794
+ root = stateDir(),
795
+ now = new Date(),
796
+ ): ReleaseDriftSummary | undefined {
797
+ const drift = auditRowsToday(project, root, now).filter((row) => row.shape !== SHARED_HOST_SHAPE);
798
+ const latest = drift[drift.length - 1];
799
+ if (latest === undefined) return undefined;
800
+ return { count: drift.length, latest };
801
+ }
802
+
803
+ export interface SharedHostGuardSummary {
804
+ count: number;
805
+ latest: ReleaseBlock;
806
+ }
807
+
808
+ /**
809
+ * Aggregate today's shared-host gate refusals (#562), apart from release
810
+ * drift. Every row records a worker being correctly stopped — the whole
811
+ * package runs and host shell suites that once SIGTERMed the production
812
+ * daemon — so the count and the latest {@link ReleaseBlock#matched matched
813
+ * command} are the operator's signal when a worker keeps pushing at the gate
814
+ * (tonight's clustering is how #558's wrapper evasion was found), and they
815
+ * must never ride the release-policy line.
816
+ */
817
+ export function sharedHostRefusalsToday(
818
+ project: string,
819
+ root = stateDir(),
820
+ now = new Date(),
821
+ ): SharedHostGuardSummary | undefined {
822
+ const blocked = auditRowsToday(project, root, now).filter((row) => row.shape === SHARED_HOST_SHAPE);
823
+ const latest = blocked[blocked.length - 1];
824
+ if (latest === undefined) return undefined;
825
+ return { count: blocked.length, latest };
514
826
  }
515
827
 
516
828
  export function releaseDriftDigestLine(
@@ -535,3 +847,36 @@ export function releaseDriftDigestLine(
535
847
  "Include this divergence from releasePolicy=none in today's digest."
536
848
  );
537
849
  }
850
+
851
+ /**
852
+ * The tick/digest line for the shared-host guard (#562), deliberately apart
853
+ * from {@link releaseDriftDigestLine}: a shared-host refusal is a worker
854
+ * being correctly stopped, never a release-policy divergence, and naming a
855
+ * release grant here would misattribute an event no grant covers (#549). A
856
+ * working guard reads as a working guard; the guard name and the matched
857
+ * command are the genuinely useful parts, because a clustering of refusals
858
+ * is itself a signal.
859
+ */
860
+ export function sharedHostGuardDigestLine(
861
+ project: string,
862
+ root = stateDir(),
863
+ now = new Date(),
864
+ ): string | undefined {
865
+ const refusal = sharedHostRefusalsToday(project, root, now);
866
+ if (refusal === undefined) return undefined;
867
+ const latest = refusal.latest;
868
+ const attribution = [
869
+ `${latest.source} ${latest.shape} at ${latest.at}`,
870
+ ...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
871
+ ...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
872
+ ...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
873
+ ...(latest.invocation === undefined ? [] : [`attempted "${latest.invocation}"`]),
874
+ ...(latest.matched === undefined ? [] : [`matched "${latest.matched}"`]),
875
+ ].join(", ");
876
+ return (
877
+ `The shared-host guard refused ${refusal.count} worker command(s) today ` +
878
+ `(latest: ${attribution}). ` +
879
+ "This is the shared-host interlock, not a release-policy divergence: a worker " +
880
+ "is refused these whatever grants it holds; the operator owns the host and CI owns the full suite."
881
+ );
882
+ }
@@ -22,9 +22,8 @@
22
22
  import { connect } from "node:net";
23
23
 
24
24
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
25
- import type { ReleaseBlockContext } from "./release-policy.ts";
26
-
27
- import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
25
+ import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
26
+ import type { ResolvedGrants, SessionRole } from "./types.ts";
28
27
 
29
28
  /**
30
29
  * Everything the child needs to build the session. Plain JSON on purpose:
@@ -68,7 +67,7 @@ export type HostToParent =
68
67
  | { t: "session-file"; path: string }
69
68
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
70
69
  | { t: "park-result"; id: number; ok: boolean; error?: string }
71
- | ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
70
+ | ({ t: "release-blocked"; shape: GateShape } & ReleaseBlockContext);
72
71
 
73
72
  /**
74
73
  * Depth at which a harness event stops being copied for the wire.
@@ -196,7 +195,14 @@ export async function runSessionHost(
196
195
  });
197
196
  } catch (err) {
198
197
  send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
199
- socket.end();
198
+ // The frame must reach the parent before this process exits: `process.exit`
199
+ // does not flush the socket, and a parent that only observes the exit used
200
+ // to report a bare exit code instead of the child's own words.
201
+ await new Promise<void>((resolve) => {
202
+ socket.once("finish", () => resolve());
203
+ socket.once("error", () => resolve());
204
+ socket.end();
205
+ });
200
206
  return;
201
207
  }
202
208