omp-conductor 0.15.12 → 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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The orchestrator-down incident: how the daemon learns — and tells its
3
+ * operator — that the embedded orchestrator session is gone.
4
+ *
5
+ * A failed orchestrator used to degrade quietly: the daemon logged a warning
6
+ * and kept running, but the tier-1 escalations that then fell back to issue
7
+ * comments were exactly the "nobody reads it until morning" path the
8
+ * orchestrator exists to avoid. Nothing paged, and `status` never said the
9
+ * operator channel was degraded (the pending issue is #288).
10
+ *
11
+ * This module makes three facts durable and observable:
12
+ *
13
+ * 1. **The incident.** Start failure (no handle) and unexpected session death
14
+ * (a handle whose `alive()` is false) each open one durable
15
+ * `orchestrator-incident` row, re-derived across restarts so a daemon
16
+ * restarted while still down rediscovers it. The row survives and is
17
+ * removed only on recovery.
18
+ * 2. **One page per incident.** Open, and every subsequent down tick, page a
19
+ * tier-2 urgent escalation whose key is anchored on the incident's `since`
20
+ * timestamp — the notification ledger pages once, never per tick, and a
21
+ * *new* incident (a flapping orchestrator recovering then dying again) gets
22
+ * a new anchor and pages again.
23
+ * 3. **The degrade row.** While down, `status` names the mode, the since-
24
+ * moment and how many tier-1 escalations were diverted to issue comments.
25
+ * Recovery closes the row and pages one closing notice on the same anchor,
26
+ * with downtime and the diverted count.
27
+ *
28
+ * The session liveness driving this is event-driven (the proxy's `session_exit`
29
+ * terminal event), never a timer: a wedged-but-alive session stays "alive"
30
+ * here and belongs to the `.conductor-stalled` watchdog, which this module
31
+ * deliberately does not touch.
32
+ */
33
+
34
+ import type { OrchestratorHandle } from "./orchestrator.ts";
35
+ import type {
36
+ Escalation,
37
+ OrchestratorDownMode,
38
+ OrchestratorIncident,
39
+ ProjectConfig,
40
+ Store,
41
+ } from "./types.ts";
42
+
43
+ /** Fleet-scoped pages carry issue `0` — there is no tracker issue for this. */
44
+ const NO_ISSUE = 0;
45
+
46
+ export interface OrchestratorDownDeps {
47
+ project: ProjectConfig;
48
+ store: Store;
49
+ /** The embedded session handle; `undefined` when it failed to start. */
50
+ orchestrator?: OrchestratorHandle;
51
+ /**
52
+ * The daemon's escalation transport. Errors are caught here (the call is
53
+ * best-effort and ledger-deduped), so a transport that is itself down never
54
+ * takes the tick down with it.
55
+ */
56
+ escalate(e: Escalation): Promise<void>;
57
+ /**
58
+ * Start-failure cause, only meaningful when a new incident is opened with no
59
+ * handle. Carried so the page and `status` tell the operator *why*, not just
60
+ * that the orchestrator is gone.
61
+ */
62
+ startCause?: string;
63
+ now?(): number;
64
+ log?(msg: string): void;
65
+ }
66
+
67
+ function iso(at: number): string {
68
+ return new Date(at).toISOString();
69
+ }
70
+
71
+ /** Compact, human-scale duration for downtime and ages. */
72
+ export function formatDownDuration(ms: number): string {
73
+ if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1_000))}s`;
74
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
75
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
76
+ return `${Math.round(ms / 86_400_000)}d`;
77
+ }
78
+
79
+ function warn(msg: string): void {
80
+ process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
81
+ }
82
+
83
+ function errText(e: unknown): string {
84
+ return e instanceof Error ? e.message : String(e);
85
+ }
86
+
87
+ /**
88
+ * Best-effort page, mirroring the daemon's `safeEscalate`: a transport failure
89
+ * is logged, never thrown. The recipient (the daemon's escalator) owns the
90
+ * notification-ledger dedupe, which is what makes "page once per incident" a
91
+ * property of the store rather than of this loop.
92
+ */
93
+ async function deliver(deps: OrchestratorDownDeps, e: Escalation): Promise<boolean> {
94
+ try {
95
+ await deps.escalate(e);
96
+ return true;
97
+ } catch (err) {
98
+ (deps.log ?? warn)(`orchestrator-down escalation could not be delivered: ${errText(err)}`);
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The down page. Keyed on the incident's `since` so a still-open incident never
105
+ * pages again (the ledger dedupes on the summary), while each *new* incident —
106
+ * a fresh `since` — pages once.
107
+ */
108
+ export function downEscalation(project: string, incident: OrchestratorIncident): Escalation {
109
+ return {
110
+ tier: 2,
111
+ category: "confirmed-failure",
112
+ urgent: true,
113
+ project,
114
+ issue: NO_ISSUE,
115
+ summary:
116
+ `Orchestrator down since ${iso(incident.since)} — tier-1 escalations diverting to issue comments (${project})`,
117
+ detail: [
118
+ `Mode: ${incident.mode}.`,
119
+ ...(incident.cause === undefined ? [] : [`Cause: ${incident.cause}.`]),
120
+ "",
121
+ "Tier-1 escalations still land — as issue comments, the path this orchestrator",
122
+ "exists to avoid. Dispatch, settlement and release are unaffected; the operator",
123
+ "channel is degraded. Recovery sends one closing notice on this same incident.",
124
+ ].join("\n"),
125
+ };
126
+ }
127
+
128
+ /** The closing notice, referencing the same incident anchor. */
129
+ export function recoveryEscalation(
130
+ project: string,
131
+ closed: OrchestratorIncident,
132
+ now: number,
133
+ ): Escalation {
134
+ const downtime = formatDownDuration(Math.max(0, now - closed.since));
135
+ return {
136
+ tier: 2,
137
+ category: "confirmed-failure",
138
+ urgent: true,
139
+ project,
140
+ issue: NO_ISSUE,
141
+ summary:
142
+ `Orchestrator recovered for ${project} ` +
143
+ `(down since ${iso(closed.since)}) — tier-1 escalations resume as injected prompts`,
144
+ detail: [
145
+ `Mode: ${closed.mode}. Downtime: ${downtime}.`,
146
+ `Tier-1 escalations diverted to issue comments while down: ${closed.diverted}.`,
147
+ "",
148
+ "The orchestrator is back and ticking. The .conductor-stalled wedge watchdog is",
149
+ "unchanged and still covers a session that stops draining its queue.",
150
+ ].join("\n"),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Reconcile the embedded orchestrator's liveness against the durable incident.
156
+ * Idempotent, so it is safe to call at startup (after the escalator exists) and
157
+ * on every tick: opening, paging, divert-counting and recovering all converge
158
+ * on the store.
159
+ *
160
+ * External orchestrators return early — their down state is a human-owned pane,
161
+ * not conductor's to page, and the stall watchdog still covers their wedge.
162
+ */
163
+ export async function reconcileOrchestratorDown(deps: OrchestratorDownDeps): Promise<void> {
164
+ const { project, store } = deps;
165
+ const now = deps.now ?? Date.now;
166
+ const log = deps.log ?? warn;
167
+ if (project.escalation.orchestrator === "external") return;
168
+
169
+ const handle = deps.orchestrator;
170
+ const up = handle !== undefined && handle.alive();
171
+ const existing = store.orchestratorIncident(project.name);
172
+
173
+ if (up) {
174
+ if (existing === undefined) return;
175
+ // A session is (re)started and alive: close the incident from before and
176
+ // page one closing notice on the same anchor.
177
+ const closed = store.closeOrchestratorIncident(project.name, now());
178
+ if (closed === undefined) return; // raced with another daemon
179
+ const delivered = await deliver(deps, recoveryEscalation(project.name, closed, now()));
180
+ log(
181
+ `orchestrator recovered — closed incident from ${iso(closed.since)} ` +
182
+ `(${closed.diverted} diverted)${delivered ? "" : " (page unsent)"}`,
183
+ );
184
+ return;
185
+ }
186
+
187
+ // Down. Open once (the store's INSERT OR IGNORE is the dedupe), then page —
188
+ // the page itself is ledger-deduped so later ticks re-derive the world
189
+ // without paging again.
190
+ if (existing === undefined) {
191
+ const mode: OrchestratorDownMode = handle === undefined ? "start-failed" : "crashed";
192
+ if (
193
+ !store.openOrchestratorIncident({
194
+ project: project.name,
195
+ mode,
196
+ ...(mode === "start-failed"
197
+ ? deps.startCause === undefined
198
+ ? {}
199
+ : { cause: deps.startCause }
200
+ : { cause: "session died after a healthy start" }),
201
+ since: now(),
202
+ })
203
+ ) {
204
+ return; // raced with another daemon opening it
205
+ }
206
+ }
207
+ const incident = store.orchestratorIncident(project.name);
208
+ if (incident === undefined) return;
209
+ await deliver(deps, downEscalation(project.name, incident));
210
+ log(
211
+ `ERROR: orchestrator ${incident.mode} down since ${iso(incident.since)} — ` +
212
+ `tier-1 escalations diverting to issue comments`,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * The first-class degrade row for `status`: mode, since-when, and how many
218
+ * tier-1 escalations have been diverted to issue comments. Absent entirely
219
+ * (empty array) when the orchestrator is healthy, so recovery drops the row.
220
+ */
221
+ export function formatOrchestratorDown(row: OrchestratorIncident, now = Date.now()): string[] {
222
+ return [
223
+ `orchestrator DEGRADED ${row.mode} since ${iso(row.since)} ` +
224
+ `(${formatDownDuration(Math.max(0, now - row.since))} ago)`,
225
+ ` diverting tier-1 escalations to issue comments` +
226
+ (row.diverted === 0
227
+ ? " (none yet)"
228
+ : ` — ${row.diverted} escalated issue${row.diverted === 1 ? "" : "s"} diverted`),
229
+ ...(row.cause === undefined ? [] : [` cause ${row.cause}`]),
230
+ ];
231
+ }
@@ -71,6 +71,7 @@ import {
71
71
  releaseDriftDigestLine,
72
72
  releaseRefusal,
73
73
  releaseToolMatch,
74
+ sharedHostGuardDigestLine,
74
75
  type ReleaseDecision,
75
76
  } from "./release-policy.ts";
76
77
  import {
@@ -2346,6 +2347,12 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2346
2347
  if (scope.projectName !== undefined) {
2347
2348
  const drift = releaseDriftDigestLine(scope.projectName);
2348
2349
  if (drift !== undefined) content = `${content}\n${drift}`;
2350
+ // The shared-host gate's refusals are reported on their own line, apart
2351
+ // from release drift (#562): a worker being stopped by the interlock is
2352
+ // the guard working, never a release-policy divergence, and it must not
2353
+ // move the drift counter or borrow the release-policy heading.
2354
+ const guard = sharedHostGuardDigestLine(scope.projectName);
2355
+ if (guard !== undefined) content = `${content}\n${guard}`;
2349
2356
  if (existsSync(dbPath())) {
2350
2357
  try {
2351
2358
  frictionStore = openStore(dbPath());
@@ -76,6 +76,11 @@ export interface OrchestratorHandle {
76
76
  */
77
77
  deliver(e: Escalation, project: string): Promise<DeliveryReceipt>;
78
78
  busy(): boolean;
79
+ /** Whether the underlying session is still running. Event-driven, never a
80
+ * timer: set false by the proxy's real `session_exit` terminal event, and
81
+ * false once the handle has been disposed. A wedged-but-alive session still
82
+ * reads alive here — that is the stall watchdog's job, not liveness. */
83
+ alive(): boolean;
79
84
  sessionFile(): string | undefined;
80
85
  dispose(): Promise<void>;
81
86
  }
@@ -216,6 +221,14 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
216
221
  });
217
222
 
218
223
  let disposed = false;
224
+ // Read by the handle's `alive()`: a session that has ended is gone whether
225
+ // it crashed or was shut down, and from that instant nothing about it can be
226
+ // relied on. Driven by the proxy's `session_exit` terminal event — a real
227
+ // signal, not a timer, and not the `agent_end`/streaming bookkeeping above.
228
+ let terminated = false;
229
+ session.on("session_exit", () => {
230
+ terminated = true;
231
+ });
219
232
  /** Tail of the delivery queue. Always settled-or-settling, never rejected. */
220
233
  let queue: Promise<void> = Promise.resolve();
221
234
  /** Consumed by the first injection; see {@link OrchestratorOpts.brief}. */
@@ -273,6 +286,7 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
273
286
  return accepted;
274
287
  },
275
288
  busy: () => streaming,
289
+ alive: () => !terminated && !disposed,
276
290
  // The path the harness actually opened, read live: the transcript is how a
277
291
  // human audits what the orchestrator decided on their behalf.
278
292
  sessionFile: () => session.sessionFile,
@@ -76,8 +76,10 @@ export type ReleaseDecision = { block: true; reason: string };
76
76
  * and a worker is always, whatever the config says. So it stays out of the
77
77
  * configurable grant vocabulary (`releaseShapeEnum` is drawn straight from
78
78
  * {@link RELEASE_SHAPES}); it only rides the release-policy classifier and the
79
- * block ledger so one seam sees it and its refusals land where the next tick
80
- * already reads drift.
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.
81
83
  */
82
84
  export const SHARED_HOST_SHAPE = "shared-host-gate" as const;
83
85
  export type SharedHostShape = typeof SHARED_HOST_SHAPE;
@@ -172,11 +174,66 @@ function commandSegments(command: string): string[] {
172
174
  .filter((segment) => segment.length > 0);
173
175
  }
174
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
+ */
175
230
  function stripCommandPrefix(segment: string): string {
176
- return segment
177
- .replace(/^env\s+/, "")
178
- .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*/, "")
179
- .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;
180
237
  }
181
238
 
182
239
  /** Recognise the explicit release/deploy command shapes this policy promises to gate. */
@@ -250,6 +307,17 @@ function sharedHostScriptMatch(segment: string): string | undefined {
250
307
  function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
251
308
  for (const raw of commandSegments(command)) {
252
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
+ }
253
321
  // Patching the running conductor is gated like a release act: `upgrade`,
254
322
  // the detached `upgrade-install`/`upgrade-rollback` executor entries and
255
323
  // any future spelling under the upgrade family all need the install shape,
@@ -677,21 +745,21 @@ export interface ReleaseDriftSummary {
677
745
  latest: ReleaseBlock;
678
746
  }
679
747
 
680
- /** Aggregate today's blocked attempts for the orchestrator's daily digest. */
681
- export function releaseDriftToday(
682
- project: string,
683
- root = stateDir(),
684
- now = new Date(),
685
- ): 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[] {
686
755
  let text: string;
687
756
  try {
688
757
  text = readFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), "utf8");
689
758
  } catch {
690
- return undefined;
759
+ return [];
691
760
  }
692
761
  const day = now.toISOString().slice(0, 10);
693
- let count = 0;
694
- let latest: ReleaseBlock | undefined;
762
+ const rows: ReleaseBlock[] = [];
695
763
  for (const line of text.split("\n")) {
696
764
  if (line.length === 0) continue;
697
765
  try {
@@ -703,14 +771,58 @@ export function releaseDriftToday(
703
771
  (value.source === "worker" || value.source === "orchestrator") &&
704
772
  typeof value.shape === "string"
705
773
  ) {
706
- count += 1;
707
- latest = value as ReleaseBlock;
774
+ rows.push(value as ReleaseBlock);
708
775
  }
709
776
  } catch {
710
777
  // One torn line does not hide later valid audit records.
711
778
  }
712
779
  }
713
- 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 };
714
826
  }
715
827
 
716
828
  export function releaseDriftDigestLine(
@@ -735,3 +847,36 @@ export function releaseDriftDigestLine(
735
847
  "Include this divergence from releasePolicy=none in today's digest."
736
848
  );
737
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
+ }