omp-conductor 0.20.2 → 0.20.3
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/package.json +1 -1
- package/src/commands/daemon.ts +14 -25
- package/src/daemon/tick.ts +15 -1
- package/src/diff-flags.ts +93 -9
- package/src/doctor.ts +295 -2
- package/src/fleet.ts +402 -45
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/ready-gate.ts +100 -5
- package/src/upgrade.ts +12 -5
package/src/lifecycle.ts
CHANGED
|
@@ -194,6 +194,103 @@ export function livingDaemon(): DaemonRecord | undefined {
|
|
|
194
194
|
return undefined;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* `DaemonRecord.logFile` for a daemon nobody spawned — which is how systemd
|
|
199
|
+
* runs it (`ExecStart … omp-conductor daemon`) and how a hand-run foreground
|
|
200
|
+
* `daemon` works too. The field is required and `status` prints it, so it has
|
|
201
|
+
* to say something true: a foreground daemon opened no log of its own —
|
|
202
|
+
* whoever started it owns its stdout, be that the journal, a terminal, or a
|
|
203
|
+
* pane.
|
|
204
|
+
*/
|
|
205
|
+
const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
|
|
206
|
+
|
|
207
|
+
/** What this process would publish as its own boot record, given its flags. */
|
|
208
|
+
function foregroundRecord(o: { port?: number; project?: string }): DaemonRecord {
|
|
209
|
+
return {
|
|
210
|
+
pid: process.pid,
|
|
211
|
+
port: o.port ?? DEFAULT_PORT,
|
|
212
|
+
startedAt: Date.now(),
|
|
213
|
+
logFile: FOREGROUND_LOG,
|
|
214
|
+
...(o.project === undefined ? {} : { project: o.project }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ForegroundBoot =
|
|
219
|
+
| { kind: "claimed"; record: DaemonRecord }
|
|
220
|
+
| { kind: "foreign-live"; pid: number };
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The boot half of pidfile ownership (#1114): a daemon that starts without a
|
|
224
|
+
* managed spawn — systemd's `ExecStart`, a reboot, a bare hand-run — publishes
|
|
225
|
+
* its own record here, so the single-owner guard (`startDaemon`'s live-record
|
|
226
|
+
* refusal), `wake`, `status` and orphan reconciliation are armed no matter who
|
|
227
|
+
* started the process.
|
|
228
|
+
*
|
|
229
|
+
* A foreign live record is refused, never stolen; a stale one is cleared by
|
|
230
|
+
* {@link livingDaemon} on the way past; and a record already naming this pid
|
|
231
|
+
* was written by whoever spawned us and knows the real log file — replacing
|
|
232
|
+
* it with a guess would be a downgrade.
|
|
233
|
+
*/
|
|
234
|
+
export function publishForegroundRecord(
|
|
235
|
+
o: { port?: number; project?: string } = {},
|
|
236
|
+
): ForegroundBoot {
|
|
237
|
+
const running = livingDaemon();
|
|
238
|
+
if (running !== undefined && running.pid !== process.pid) {
|
|
239
|
+
return { kind: "foreign-live", pid: running.pid };
|
|
240
|
+
}
|
|
241
|
+
if (running !== undefined) return { kind: "claimed", record: running };
|
|
242
|
+
const record = foregroundRecord(o);
|
|
243
|
+
writeRecord(record);
|
|
244
|
+
return { kind: "claimed", record };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The running half of pidfile ownership (#1114): the record can vanish under
|
|
249
|
+
* a perfectly healthy daemon — an external sweep of the shared runtime
|
|
250
|
+
* directory, a tmp cleaner — and nothing else re-arms the single-owner guard
|
|
251
|
+
* until the next restart. The daemon itself repairs that: absent or stale
|
|
252
|
+
* means it publishes again; a live foreign record is left alone (a `--once`
|
|
253
|
+
* drill may legitimately own visibility mid-tick).
|
|
254
|
+
*/
|
|
255
|
+
export function republishSelfRecord(o: { port?: number; project?: string } = {}): void {
|
|
256
|
+
const current = livingDaemon();
|
|
257
|
+
// Ours is already published, or a live foreign process owns visibility:
|
|
258
|
+
// either way there is nothing to repair.
|
|
259
|
+
if (current !== undefined) return;
|
|
260
|
+
writeRecord(foregroundRecord(o));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** How often a running daemon re-asserts its record ({@link republishSelfRecord}). */
|
|
264
|
+
export const RECORD_REPAIR_INTERVAL_MS = 60_000;
|
|
265
|
+
|
|
266
|
+
/** Stops the periodic repair started by {@link startRecordRepair}. */
|
|
267
|
+
export interface RecordRepair {
|
|
268
|
+
stop(): void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Re-assert the record on an interval while the daemon runs. A failed repair
|
|
273
|
+
* never propagates: the pidfile is visibility, not correctness, and a failed
|
|
274
|
+
* attempt retries on the next tick rather than killing the dispatcher over it.
|
|
275
|
+
*/
|
|
276
|
+
export function startRecordRepair(
|
|
277
|
+
o: { port?: number; project?: string } = {},
|
|
278
|
+
intervalMs = RECORD_REPAIR_INTERVAL_MS,
|
|
279
|
+
): RecordRepair {
|
|
280
|
+
const timer = setInterval(() => {
|
|
281
|
+
try {
|
|
282
|
+
republishSelfRecord(o);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
process.stderr.write(
|
|
285
|
+
`omp-conductor: could not re-assert the daemon pidfile: ${(err as Error).message}\n`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}, intervalMs);
|
|
289
|
+
// Bookkeeping must never hold the event loop open at shutdown.
|
|
290
|
+
timer.unref?.();
|
|
291
|
+
return { stop: () => clearInterval(timer) };
|
|
292
|
+
}
|
|
293
|
+
|
|
197
294
|
/**
|
|
198
295
|
* The `daemon --once` lease file, one per process. Created `O_EXCL` so two
|
|
199
296
|
* simultaneous `--once` runs cannot both win ownership, and it names the
|
|
@@ -1186,6 +1283,10 @@ export type SystemctlResult = {
|
|
|
1186
1283
|
stderr: string;
|
|
1187
1284
|
/** Binary not found (ENOENT). Distinct from a failed invocation of a present binary. */
|
|
1188
1285
|
missing?: boolean;
|
|
1286
|
+
/** The client bound expired (spawnSync ETIMEDOUT). A state-changing verb
|
|
1287
|
+
* that reports this is re-judged from the unit's observed state before it
|
|
1288
|
+
* becomes a failure (#1112). */
|
|
1289
|
+
timedOut?: boolean;
|
|
1189
1290
|
};
|
|
1190
1291
|
|
|
1191
1292
|
export type SystemctlFn = (args: string[]) => SystemctlResult;
|
|
@@ -1251,14 +1352,169 @@ function testSystemctl(args: string[]): SystemctlResult {
|
|
|
1251
1352
|
}
|
|
1252
1353
|
}
|
|
1253
1354
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1355
|
+
/** Verbs whose completion is a unit job: systemd holds the client until the
|
|
1356
|
+
* stop/start finishes, which legitimately takes as long as the unit's own
|
|
1357
|
+
* graceful-shutdown budget. Everything else answers from manager state and
|
|
1358
|
+
* keeps the short probe bound (#1112). */
|
|
1359
|
+
const STATE_CHANGING_VERBS: Record<string, true> = {
|
|
1360
|
+
start: true,
|
|
1361
|
+
stop: true,
|
|
1362
|
+
restart: true,
|
|
1363
|
+
"try-restart": true,
|
|
1364
|
+
reload: true,
|
|
1365
|
+
"reload-or-restart": true,
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Bound for probe verbs (`show`, `is-active`, `is-enabled`, …): a hung dbus
|
|
1370
|
+
* becomes `unknown` ownership instead of a hang. Callers must NOT treat that
|
|
1371
|
+
* timeout as "no unit". State-changing verbs get their own budget below — a
|
|
1372
|
+
* 15s bound on `restart` is what declared a healthy restart failed and rolled
|
|
1373
|
+
* a good release back (#1112).
|
|
1374
|
+
*/
|
|
1375
|
+
const SYSTEMCTL_PROBE_TIMEOUT_MS = 15_000;
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* systemd's built-in `TimeoutStopSec`: how long any unit without an explicit
|
|
1379
|
+
* override may legitimately spend stopping before systemd itself escalates.
|
|
1380
|
+
* A client bound for a state-changing verb never sits below this floor —
|
|
1381
|
+
* smaller is structurally able to lose against a healthy, graceful stop.
|
|
1382
|
+
*/
|
|
1383
|
+
const SYSTEMD_DEFAULT_TIMEOUT_STOP_MS = 90_000;
|
|
1384
|
+
|
|
1385
|
+
/** Slack over the unit's own stop budget: the start half of a restart plus
|
|
1386
|
+
* scheduling delay, before the client's stopwatch means anything at all. */
|
|
1387
|
+
const STATE_VERB_SLACK_MS = 30_000;
|
|
1388
|
+
|
|
1389
|
+
type RawSystemctl = (args: string[], timeoutMs: number) => SystemctlResult;
|
|
1390
|
+
|
|
1391
|
+
/** One unit-state reading, in the shape the post-timeout verdict needs. */
|
|
1392
|
+
interface UnitStateSample {
|
|
1393
|
+
activeState?: string;
|
|
1394
|
+
/** `ExecMainStartTimestampMonotonic` — changes on every fresh start of the
|
|
1395
|
+
* unit, which is what makes "the old instance" distinguishable. */
|
|
1396
|
+
startStampMonotonic?: string;
|
|
1397
|
+
/** `TimeoutStopUSec` — integer microseconds, or `infinity`. */
|
|
1398
|
+
timeoutStopUSec?: string;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const STATE_SAMPLE_PROPERTIES = [
|
|
1402
|
+
"--property=ActiveState",
|
|
1403
|
+
"--property=ExecMainStartTimestampMonotonic",
|
|
1404
|
+
"--property=TimeoutStopUSec",
|
|
1405
|
+
] as const;
|
|
1406
|
+
|
|
1407
|
+
function sampleUnitState(unit: string, raw: RawSystemctl): UnitStateSample | undefined {
|
|
1408
|
+
const ran = raw(
|
|
1409
|
+
["show", unit, "--value", ...STATE_SAMPLE_PROPERTIES],
|
|
1410
|
+
SYSTEMCTL_PROBE_TIMEOUT_MS,
|
|
1411
|
+
);
|
|
1412
|
+
if (!ran.ok) return undefined;
|
|
1413
|
+
const values = ran.stdout.split("\n");
|
|
1414
|
+
const pick = (index: number): string | undefined => {
|
|
1415
|
+
const value = values[index]?.trim();
|
|
1416
|
+
return value === undefined || value === "" ? undefined : value;
|
|
1417
|
+
};
|
|
1418
|
+
return {
|
|
1419
|
+
activeState: pick(0),
|
|
1420
|
+
startStampMonotonic: pick(1),
|
|
1421
|
+
timeoutStopUSec: pick(2),
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The client bound for one state-changing verb: at least the unit's own
|
|
1427
|
+
* `TimeoutStopUSec` — read live, because any fixed constant re-creates this
|
|
1428
|
+
* bug on whatever host drains slower than it — and never below systemd's own
|
|
1429
|
+
* default stop budget. An unreadable budget gets the documented fallback.
|
|
1430
|
+
*/
|
|
1431
|
+
function stateVerbTimeoutMs(sample: UnitStateSample | undefined): number {
|
|
1432
|
+
let stopMs = SYSTEMD_DEFAULT_TIMEOUT_STOP_MS;
|
|
1433
|
+
const usec =
|
|
1434
|
+
sample?.timeoutStopUSec === undefined ? Number.NaN : Number.parseInt(sample.timeoutStopUSec, 10);
|
|
1435
|
+
if (Number.isFinite(usec) && usec > 0) {
|
|
1436
|
+
stopMs = Math.max(Math.ceil(usec / 1000), stopMs);
|
|
1437
|
+
}
|
|
1438
|
+
return stopMs + STATE_VERB_SLACK_MS;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* The stopwatch lost; the unit's own state decides. A state-changing verb that
|
|
1443
|
+
* outlived the client bound succeeded exactly when the manager shows the
|
|
1444
|
+
* verb's goal reached: not-active for `stop`, active with a FRESH start
|
|
1445
|
+
* timestamp for the start/restart family — an unchanged timestamp means the
|
|
1446
|
+
* old instance is still the running one. Anything else stays a failure that
|
|
1447
|
+
* carries both facts.
|
|
1448
|
+
*/
|
|
1449
|
+
function stateVerbTimeoutVerdict(
|
|
1450
|
+
verb: string,
|
|
1451
|
+
unit: string,
|
|
1452
|
+
before: UnitStateSample | undefined,
|
|
1453
|
+
after: UnitStateSample | undefined,
|
|
1454
|
+
timedOut: SystemctlResult,
|
|
1455
|
+
timeoutMs: number,
|
|
1456
|
+
): SystemctlResult {
|
|
1457
|
+
const base =
|
|
1458
|
+
`systemctl ${verb} ${unit} exceeded its ${Math.round(timeoutMs / 1000)}s client bound ` +
|
|
1459
|
+
`(${timedOut.stderr.trim() || "client timeout"})`;
|
|
1460
|
+
if (after?.activeState === undefined) {
|
|
1461
|
+
return {
|
|
1462
|
+
...timedOut,
|
|
1463
|
+
stderr:
|
|
1464
|
+
`${base}, and the unit's state could not be re-read afterwards — ` +
|
|
1465
|
+
`the outcome is unproven, not failed`,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
if (verb === "stop") {
|
|
1469
|
+
if (after.activeState !== "active" && after.activeState !== "deactivating") {
|
|
1470
|
+
// Goal state reached: the stop completed, whatever the stopwatch said.
|
|
1471
|
+
return { ok: true, stdout: "", stderr: "" };
|
|
1472
|
+
}
|
|
1473
|
+
return {
|
|
1474
|
+
...timedOut,
|
|
1475
|
+
stderr: `${base}; the unit is still ${after.activeState} — the stop did not complete`,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
const freshStart =
|
|
1479
|
+
before !== undefined &&
|
|
1480
|
+
after.startStampMonotonic !== undefined &&
|
|
1481
|
+
after.startStampMonotonic !== before.startStampMonotonic;
|
|
1482
|
+
if (after.activeState === "active" && freshStart) {
|
|
1483
|
+
// The unit restarted; the client simply gave up first.
|
|
1484
|
+
return { ok: true, stdout: "", stderr: "" };
|
|
1485
|
+
}
|
|
1486
|
+
const why =
|
|
1487
|
+
after.activeState !== "active"
|
|
1488
|
+
? `the unit is ${after.activeState}`
|
|
1489
|
+
: before === undefined
|
|
1490
|
+
? "the unit is active but no pre-call sample exists to prove the start is fresh"
|
|
1491
|
+
: "its start timestamp is unchanged, so the running instance predates the call";
|
|
1492
|
+
return { ...timedOut, stderr: `${base}; ${why} — the ${verb} did not complete` };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Run one systemctl invocation with #1112 semantics: probes keep the short
|
|
1497
|
+
* hung-dbus bound, state-changing verbs wait out the unit's own stop budget,
|
|
1498
|
+
* and a state-changing verb that outlives even that is judged by the unit's
|
|
1499
|
+
* observed state rather than the client's stopwatch. Exported for tests,
|
|
1500
|
+
* which inject their own {@link RawSystemctl}.
|
|
1501
|
+
*/
|
|
1502
|
+
export function runSystemctlInvocation(args: string[], raw: RawSystemctl): SystemctlResult {
|
|
1503
|
+
const verb = args[0] ?? "";
|
|
1504
|
+
if (STATE_CHANGING_VERBS[verb] !== true) return raw(args, SYSTEMCTL_PROBE_TIMEOUT_MS);
|
|
1505
|
+
const unit = args.length > 1 && !args[1]!.startsWith("-") ? args[1]! : SYSTEMD_UNIT;
|
|
1506
|
+
const before = sampleUnitState(unit, raw);
|
|
1507
|
+
const timeoutMs = stateVerbTimeoutMs(before);
|
|
1508
|
+
const ran = raw(args, timeoutMs);
|
|
1509
|
+
if (ran.ok || !ran.timedOut) return ran;
|
|
1510
|
+
return stateVerbTimeoutVerdict(verb, unit, before, sampleUnitState(unit, raw), ran, timeoutMs);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function spawnSystemctl(args: string[], timeoutMs: number): SystemctlResult {
|
|
1256
1514
|
try {
|
|
1257
1515
|
const res = spawnSync("systemctl", args, {
|
|
1258
1516
|
encoding: "utf8",
|
|
1259
|
-
|
|
1260
|
-
// Callers must NOT treat that timeout as "no unit".
|
|
1261
|
-
timeout: 15_000,
|
|
1517
|
+
timeout: timeoutMs,
|
|
1262
1518
|
env: process.env,
|
|
1263
1519
|
});
|
|
1264
1520
|
if (res.error) {
|
|
@@ -1268,6 +1524,7 @@ function defaultSystemctl(args: string[]): SystemctlResult {
|
|
|
1268
1524
|
stdout: "",
|
|
1269
1525
|
stderr: err.message,
|
|
1270
1526
|
...(err.code === "ENOENT" ? { missing: true } : {}),
|
|
1527
|
+
...(err.code === "ETIMEDOUT" ? { timedOut: true } : {}),
|
|
1271
1528
|
};
|
|
1272
1529
|
}
|
|
1273
1530
|
return {
|
|
@@ -1280,6 +1537,11 @@ function defaultSystemctl(args: string[]): SystemctlResult {
|
|
|
1280
1537
|
}
|
|
1281
1538
|
}
|
|
1282
1539
|
|
|
1540
|
+
function defaultSystemctl(args: string[]): SystemctlResult {
|
|
1541
|
+
if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
|
|
1542
|
+
return runSystemctlInvocation(args, spawnSystemctl);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1283
1545
|
let systemctl: SystemctlFn = defaultSystemctl;
|
|
1284
1546
|
|
|
1285
1547
|
/** Shared manager runner. Production reaches the hardcoded binary; test
|
package/src/ready-gate.ts
CHANGED
|
@@ -95,6 +95,65 @@ export function acceptanceCriteriaSection(text: string): { heading: string; crit
|
|
|
95
95
|
return { heading: lines[start]!.trim(), criteria };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** The leading run of backticked tokens in a bullet: `` `a.ts`, `b/c.ts` ``
|
|
99
|
+
* reads as two paths, while a backticked token after prose is prose, never a
|
|
100
|
+
* declaration. Mirrors admission's private helper of the same shape — the
|
|
101
|
+
* gate cannot import it, and a second grammar beside the first would make
|
|
102
|
+
* issues that render correctly for one parse wrongly for the other. */
|
|
103
|
+
function leadingBacktickedRun(text: string): string[] {
|
|
104
|
+
const trimmed = text.trim();
|
|
105
|
+
if (!trimmed.startsWith("`")) return [];
|
|
106
|
+
const run: string[] = [];
|
|
107
|
+
let pos = 0;
|
|
108
|
+
while (true) {
|
|
109
|
+
if (trimmed[pos] !== "`") break;
|
|
110
|
+
const close = trimmed.indexOf("`", pos + 1);
|
|
111
|
+
if (close < 0) break;
|
|
112
|
+
const token = trimmed.slice(pos + 1, close).trim();
|
|
113
|
+
if (token === "") break;
|
|
114
|
+
run.push(token);
|
|
115
|
+
pos = close + 1;
|
|
116
|
+
const sep = /^[,\s]+/.exec(trimmed.slice(pos));
|
|
117
|
+
if (sep === null) break;
|
|
118
|
+
pos += sep[0].length;
|
|
119
|
+
}
|
|
120
|
+
return run;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The read-only entry points an issue declares: files its worker must read —
|
|
125
|
+
* the caller to understand, the file that proves the convention — but never
|
|
126
|
+
* write (#1108). The grammar mirrors the write-lane section: a `#`-to-`######`
|
|
127
|
+
* heading spelling `Read-only` optionally followed by `entry points`, an
|
|
128
|
+
* optional trailing colon, then the contiguous bullet run any non-bullet line
|
|
129
|
+
* ends; each bullet contributes its leading backticked run, so an issue that
|
|
130
|
+
* renders correctly for the lane sections renders correctly here too. Unlike
|
|
131
|
+
* the lane there is no inline form and no bare-token fallback: marking a file
|
|
132
|
+
* read-only is a deliberate act, and the backticked bullet is the whole shape.
|
|
133
|
+
*
|
|
134
|
+
* Every occurrence in the text counts, where a lane declaration replaces:
|
|
135
|
+
* read-only lists accumulate, so a correction comment adds references without
|
|
136
|
+
* restating the body's.
|
|
137
|
+
*/
|
|
138
|
+
export function readOnlyEntryPoints(text: string): string[] {
|
|
139
|
+
const files: string[] = [];
|
|
140
|
+
let inSection = false;
|
|
141
|
+
for (const line of text.split("\n")) {
|
|
142
|
+
if (/^[ \t]*#{1,6}[ \t]+read[-\s]only(?:[ \t]+entry[ \t]+points)?[ \t]*[:.]?[ \t]*$/i.test(line)) {
|
|
143
|
+
inSection = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!inSection || line.trim() === "") continue;
|
|
147
|
+
const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
|
|
148
|
+
if (marker === null) {
|
|
149
|
+
inSection = false;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
files.push(...leadingBacktickedRun(line.slice(marker[0].length)));
|
|
153
|
+
}
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
|
|
98
157
|
/** Trimmed non-empty strings out of a value the type system claims is a string
|
|
99
158
|
* array. Defensive because the gate is the last thing between a hand-edited
|
|
100
159
|
* store row and a dispatched worker: a malformed field is a refusal, never a
|
|
@@ -114,7 +173,7 @@ function strings(value: unknown): string[] {
|
|
|
114
173
|
* May this verdict be promoted mechanically?
|
|
115
174
|
*
|
|
116
175
|
* Every check is one named miss. The order is fixed — verdict, readability,
|
|
117
|
-
* acceptance criteria, write lane, proof commands, sizing evidence,
|
|
176
|
+
* acceptance criteria, write lane, entry points, proof commands, sizing evidence,
|
|
118
177
|
* dependencies, premise, routing, state labels — so the same input always
|
|
119
178
|
* produces the same list and a digest line is stable across ticks.
|
|
120
179
|
*
|
|
@@ -181,6 +240,7 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
|
|
|
181
240
|
? undefined
|
|
182
241
|
: (writeLaneSectionHeading(body) ??
|
|
183
242
|
comments.map((comment) => writeLaneSectionHeading(comment.body)).find((found) => found !== undefined));
|
|
243
|
+
const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
|
|
184
244
|
if (verdictLane.length === 0) {
|
|
185
245
|
// The schema requires at least one path, so this is a hand-edited or
|
|
186
246
|
// corrupt row. An empty lane on both sides would otherwise *match*, and
|
|
@@ -190,11 +250,46 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
|
|
|
190
250
|
missing.push(
|
|
191
251
|
`the write-lane section (${heading}) parsed no path-like files — name them as backticked bullets directly under the heading`,
|
|
192
252
|
);
|
|
193
|
-
} else {
|
|
194
|
-
|
|
195
|
-
|
|
253
|
+
} else if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
|
|
254
|
+
missing.push(
|
|
255
|
+
`the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// An entry point outside the lane is usually legitimate — the reference
|
|
260
|
+
// file, the caller to understand first — so the gate refuses the *unstated*
|
|
261
|
+
// one, not the idea (#1108): eight settlement flags in one day were lane
|
|
262
|
+
// escapes, several into files the issue itself had already named as an
|
|
263
|
+
// entry point. Putting the file in the lane it is meant to be written in,
|
|
264
|
+
// or listing it under a `## Read-only entry points` heading, both satisfy
|
|
265
|
+
// the check; a hard refusal for every reference would push promoters into
|
|
266
|
+
// stopping the one field workers most need.
|
|
267
|
+
//
|
|
268
|
+
// Judged only when the two lane lists agree: a disagreement is refused
|
|
269
|
+
// anyway, and entry points computed against a lane about to be rewritten
|
|
270
|
+
// would be noise stacked on the actionable fact. No parsed lane means
|
|
271
|
+
// nothing to stand outside of.
|
|
272
|
+
const entryPoints = [...new Set(strings(result.entryPoints))];
|
|
273
|
+
const lanesAgree =
|
|
274
|
+
verdictLane.length > 0 &&
|
|
275
|
+
declared.length === verdictLane.length &&
|
|
276
|
+
declared.every((path, index) => path === verdictLane[index]);
|
|
277
|
+
if (entryPoints.length > 0 && lanesAgree) {
|
|
278
|
+
const readOnly = [
|
|
279
|
+
...new Set([body, ...comments.map((comment) => comment.body)].flatMap((text) => readOnlyEntryPoints(text))),
|
|
280
|
+
];
|
|
281
|
+
// Exact member or anything under a trailing-slash directory entry — the
|
|
282
|
+
// containment the lane enforcement itself applies to written files
|
|
283
|
+
// (diff-flags.ts:withinLane).
|
|
284
|
+
const escaped = entryPoints.filter(
|
|
285
|
+
(path) =>
|
|
286
|
+
!declared.includes(path) &&
|
|
287
|
+
!declared.some((entry) => entry.endsWith("/") && path.startsWith(entry)) &&
|
|
288
|
+
!readOnly.includes(path),
|
|
289
|
+
);
|
|
290
|
+
if (escaped.length > 0) {
|
|
196
291
|
missing.push(
|
|
197
|
-
`
|
|
292
|
+
`entry points ${escaped.join(", ")} are neither in the issue's write lane [${declared.join(", ")}] nor marked read-only — put each in the \`## Exact write lane\` bullets meant for writing, or declare it under \`## Read-only entry points\``,
|
|
198
293
|
);
|
|
199
294
|
}
|
|
200
295
|
}
|
package/src/upgrade.ts
CHANGED
|
@@ -1696,7 +1696,8 @@ export async function upgradeConductor(
|
|
|
1696
1696
|
journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
|
|
1697
1697
|
const failure = err instanceof Error ? err.message : String(err);
|
|
1698
1698
|
throw new Error(
|
|
1699
|
-
`upgrade failed while draining workers: ${failure}; no installation started;
|
|
1699
|
+
`upgrade failed while draining workers: ${failure}; no installation started; ` +
|
|
1700
|
+
`dispatch remains paused — resume dispatch with \`omp-conductor resume --all\``,
|
|
1700
1701
|
);
|
|
1701
1702
|
}
|
|
1702
1703
|
journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
|
|
@@ -1916,7 +1917,8 @@ export async function upgradeConductor(
|
|
|
1916
1917
|
`Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
|
|
1917
1918
|
`herdr=${surfaces.herdrSource ?? "missing"}`,
|
|
1918
1919
|
...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
|
|
1919
|
-
"Dispatch stays paused until the first tick after the restart records the rollback verified
|
|
1920
|
+
"Dispatch stays paused until the first tick after the restart records the rollback verified; " +
|
|
1921
|
+
"if it is still paused, resume it with `omp-conductor resume --all`.",
|
|
1920
1922
|
],
|
|
1921
1923
|
`upgrade:${release.version}:rolled-back`,
|
|
1922
1924
|
);
|
|
@@ -1931,16 +1933,21 @@ export async function upgradeConductor(
|
|
|
1931
1933
|
`Failure: ${failure}`,
|
|
1932
1934
|
installAttribution,
|
|
1933
1935
|
`Rollback failure: ${rollback}`,
|
|
1934
|
-
"The fleet may be at mixed versions; do not resume dispatch.",
|
|
1936
|
+
"The fleet may be at mixed versions; do not resume dispatch until versions are verified.",
|
|
1937
|
+
"Once one version is confirmed everywhere, `omp-conductor resume --all` lifts the pause.",
|
|
1935
1938
|
"Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
|
|
1936
1939
|
],
|
|
1937
1940
|
`upgrade:${release.version}:rollback-failed`,
|
|
1938
1941
|
);
|
|
1939
1942
|
throw new Error(
|
|
1940
|
-
`upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused
|
|
1943
|
+
`upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused — ` +
|
|
1944
|
+
`\`omp-conductor resume --all\` lifts the pause once mixed versions are ruled out`,
|
|
1941
1945
|
);
|
|
1942
1946
|
}
|
|
1943
|
-
throw new Error(
|
|
1947
|
+
throw new Error(
|
|
1948
|
+
`upgrade failed: ${failure}; previous installation restored; dispatch remains paused — ` +
|
|
1949
|
+
`resume dispatch with \`omp-conductor resume --all\``,
|
|
1950
|
+
);
|
|
1944
1951
|
}
|
|
1945
1952
|
|
|
1946
1953
|
// The install landed; name any host-runtime destination this version no
|