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.
- package/REFERENCE.md +9 -4
- package/package.json +1 -1
- package/src/commands/restart.ts +81 -54
- package/src/commands/stop.ts +45 -22
- package/src/daemon.ts +253 -54
- package/src/doctor.ts +241 -4
- package/src/escalate.ts +8 -0
- package/src/fleet.ts +49 -2
- package/src/lifecycle.ts +113 -2
- package/src/omp.ts +24 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +7 -0
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +278 -113
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +181 -0
- package/src/types.ts +111 -0
- package/src/upgrade.ts +26 -6
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +21 -0
|
@@ -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
|
+
}
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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());
|
package/src/orchestrator.ts
CHANGED
|
@@ -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,
|
package/src/release-policy.ts
CHANGED
|
@@ -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
|
|
80
|
-
*
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
.
|
|
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
|
-
/**
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
759
|
+
return [];
|
|
691
760
|
}
|
|
692
761
|
const day = now.toISOString().slice(0, 10);
|
|
693
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|