@intentius/chant 0.94.0 → 0.95.1
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/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -4
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +14 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/operator.d.ts +90 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/steward-beside.d.ts +84 -0
- package/dist/op/steward-beside.d.ts.map +1 -0
- package/dist/op/steward.d.ts +87 -2
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +11 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +5 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +37 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +57 -1
- package/dist/workspace/graph-cache.d.ts +168 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -5
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/kind-readers.d.ts +39 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +29 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +15 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-run.d.ts +2 -0
- package/dist/workspace/member-run.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +10 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +4 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +23 -7
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +12 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +26 -0
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +7 -3
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
- package/src/cli/handlers/operator.ts +53 -11
- package/src/cli/handlers/run.test.ts +71 -0
- package/src/cli/handlers/run.ts +65 -7
- package/src/cli/main.ts +17 -6
- package/src/cli/mcp/server.test.ts +14 -0
- package/src/cli/mcp/server.ts +10 -4
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/static-config-read.test.ts +8 -2
- package/src/meta/source-is-text.test.ts +21 -3
- package/src/okf.test.ts +6 -1
- package/src/op/builders.ts +14 -3
- package/src/op/index.ts +8 -2
- package/src/op/operator.ts +264 -16
- package/src/op/steward-beside.test.ts +267 -0
- package/src/op/steward-beside.ts +219 -0
- package/src/op/steward-points.test.ts +61 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/checks/boxes.test.ts +1 -0
- package/src/workspace/compose-graph.test.ts +1 -0
- package/src/workspace/compose-graph.ts +11 -0
- package/src/workspace/composites.test.ts +1 -1
- package/src/workspace/composites.ts +12 -5
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
- package/src/workspace/declaration.schema.json +57 -1
- package/src/workspace/declaration.ts +104 -0
- package/src/workspace/graph-cache.test.ts +343 -0
- package/src/workspace/graph-cache.ts +409 -0
- package/src/workspace/graph-cli.ts +93 -26
- package/src/workspace/graph-contract.test.ts +129 -6
- package/src/workspace/graph.schema.json +21 -1
- package/src/workspace/kind-readers.e2e.test.ts +68 -0
- package/src/workspace/kind-readers.test.ts +134 -0
- package/src/workspace/kind-readers.ts +111 -0
- package/src/workspace/kinds.test.ts +49 -0
- package/src/workspace/kinds.ts +59 -2
- package/src/workspace/member-commands.test.ts +15 -0
- package/src/workspace/member-commands.ts +47 -7
- package/src/workspace/member-run.ts +10 -2
- package/src/workspace/reason-codes.ts +3 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +20 -12
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-new.schema.json +1 -0
- package/src/workspace/records-quorum.test.ts +10 -3
- package/src/workspace/records-sessions-write.test.ts +2 -1
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +31 -1
- package/src/workspace/status-stewards.ts +39 -6
- package/src/workspace/status.schema.json +49 -4
- package/src/workspace/status.ts +22 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- package/src/workspace/workspace-kinds.schema.json +26 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2872 — `chant operator --steward` releases its lease on a repeated
|
|
3
|
+
* SIGTERM.
|
|
4
|
+
*
|
|
5
|
+
* A supervisor that signals both a process group and a child (arugula-salad
|
|
6
|
+
* /studio's box smoke does exactly this) sends a second SIGTERM close behind
|
|
7
|
+
* the first. `process.once("SIGTERM", onSigint)` tears the listener down the
|
|
8
|
+
* instant it fires the first time — before `onSigint` even runs, let alone
|
|
9
|
+
* before the `finally` block that releases the steward's lease — so that
|
|
10
|
+
* second signal finds nothing listening and takes Node's default
|
|
11
|
+
* disposition: the process ends right there. The next `chant operator
|
|
12
|
+
* --steward` then refuses with "already running (lease held by ...)" until
|
|
13
|
+
* the stale lease expires.
|
|
14
|
+
*
|
|
15
|
+
* This spawns the real CLI (not the handler in-process) so a broken fix
|
|
16
|
+
* kills only the child, never the test runner itself, the way `process.once`
|
|
17
|
+
* would if it were still there. See ../../cli/handlers/operator.ts around
|
|
18
|
+
* "chant operator --steward" for the fix (`process.on`, kept registered for
|
|
19
|
+
* the whole shutdown).
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
25
|
+
import { cleanScratch, repo, REPO } from "../../workspace/__fixtures__/contract-repo";
|
|
26
|
+
import { readLease } from "../../lifecycle/lease";
|
|
27
|
+
import { stewardLeaseName } from "../../op/steward";
|
|
28
|
+
|
|
29
|
+
const MAIN = join(REPO, "packages/core/src/cli/main.ts");
|
|
30
|
+
const LOADER = pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href;
|
|
31
|
+
const CHANT = [process.execPath, "--import", LOADER, MAIN];
|
|
32
|
+
|
|
33
|
+
/** The shape `declareSteward({ name: "box-steward", ops: [] })` returns — written as a literal so the fixture's `ops/steward.op.ts` needs no import to resolve. */
|
|
34
|
+
const STEWARD_DECLARATION = {
|
|
35
|
+
kind: "Chant::Steward",
|
|
36
|
+
name: "box-steward",
|
|
37
|
+
ops: [],
|
|
38
|
+
form: { default: "local", environments: {} },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
afterAll(() => cleanScratch());
|
|
42
|
+
|
|
43
|
+
function sleep(ms: number): Promise<void> {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("chant operator --steward — a repeated SIGTERM (#2872)", () => {
|
|
48
|
+
test("a second and third SIGTERM sent in quick succession still release the steward's lease", async () => {
|
|
49
|
+
const dir = repo(
|
|
50
|
+
{
|
|
51
|
+
"chant.config.ts": "export default { lexicons: [] };\n",
|
|
52
|
+
"ops/steward.op.ts": `export const steward = ${JSON.stringify(STEWARD_DECLARATION)};\n`,
|
|
53
|
+
},
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const child = spawn(
|
|
58
|
+
CHANT[0],
|
|
59
|
+
[...CHANT.slice(1), "operator", "--steward", "--interval", "200ms"],
|
|
60
|
+
{ cwd: dir, env: { ...process.env, NO_COLOR: "1", TSX_DISABLE_CACHE: "1" } },
|
|
61
|
+
);
|
|
62
|
+
let stderr = "";
|
|
63
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
64
|
+
const exited = new Promise<number | null>((resolve) => child.on("close", resolve));
|
|
65
|
+
|
|
66
|
+
// Wait until the steward has actually acquired its lease — a running,
|
|
67
|
+
// mid-loop process — rather than firing signals at one still starting up
|
|
68
|
+
// (tsx's cold start is not instant).
|
|
69
|
+
const deadline = Date.now() + 15_000;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const { record } = await readLease(stewardLeaseName("box-steward"), { cwd: dir });
|
|
72
|
+
if (record) break;
|
|
73
|
+
if (Date.now() > deadline) {
|
|
74
|
+
child.kill("SIGKILL");
|
|
75
|
+
throw new Error(`the steward never acquired its lease; stderr so far:\n${stderr}`);
|
|
76
|
+
}
|
|
77
|
+
await sleep(50);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Three SIGTERMs, each given a moment to actually reach the child's event
|
|
81
|
+
// loop before the next: with `process.once`, the first one's auto-removal
|
|
82
|
+
// happens the instant it fires, so the second (or third) finds no
|
|
83
|
+
// listener left and the process exits right there, skipping the
|
|
84
|
+
// `finally` that releases the lease.
|
|
85
|
+
child.kill("SIGTERM");
|
|
86
|
+
await sleep(30);
|
|
87
|
+
child.kill("SIGTERM");
|
|
88
|
+
await sleep(30);
|
|
89
|
+
child.kill("SIGTERM");
|
|
90
|
+
|
|
91
|
+
const code = await exited;
|
|
92
|
+
expect(code, `chant operator --steward did not exit cleanly; stderr:\n${stderr}`).toBe(0);
|
|
93
|
+
|
|
94
|
+
const { record } = await readLease(stewardLeaseName("box-steward"), { cwd: dir });
|
|
95
|
+
expect(record, "the steward's lease was not released").toBeUndefined();
|
|
96
|
+
}, 20_000);
|
|
97
|
+
});
|
|
@@ -12,7 +12,7 @@ import { build } from "../../build";
|
|
|
12
12
|
import { isResourceDeclarable } from "../../declarable";
|
|
13
13
|
import { collectBuildRootContributors, collectChangeSubscribers } from "../plugins";
|
|
14
14
|
import { discoverOps, discoverStewards } from "../../op/discover";
|
|
15
|
-
import { pickSteward, stewardFormFor, stewardLeaseName, DEFAULT_STEWARD_ENV } from "../../op/steward";
|
|
15
|
+
import { pickSteward, stewardBesideOf, stewardFormFor, stewardLeaseName, stewardTurnOps, DEFAULT_STEWARD_ENV } from "../../op/steward";
|
|
16
16
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
17
17
|
import { parseDuration } from "../../op/local-executor";
|
|
18
18
|
import {
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
formatSignalLine,
|
|
24
24
|
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
25
25
|
acquireStewardLease,
|
|
26
|
+
createBesideState,
|
|
27
|
+
waitForBesideRuns,
|
|
26
28
|
type ChangeSubscriber,
|
|
27
29
|
type OperatorSignalEvent,
|
|
28
30
|
type OperatorTickEvent,
|
|
@@ -168,11 +170,21 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
|
|
|
168
170
|
const leaseTtlMs = ctx.args.leaseTtl ? parseDuration(ctx.args.leaseTtl) : DEFAULT_LEASE_TTL_MS;
|
|
169
171
|
|
|
170
172
|
const controller = new AbortController();
|
|
173
|
+
// `process.on`, not `.once`: a `.once` listener is gone the instant it
|
|
174
|
+
// fires, so a second SIGINT arriving while shutdown is still in flight
|
|
175
|
+
// (the `finally` below hasn't run yet) has no listener left and takes
|
|
176
|
+
// Node's default disposition — the process ends right there, before
|
|
177
|
+
// whatever the `finally` was going to do. Kept registered for the whole
|
|
178
|
+
// shutdown and only removed once it's actually over; `stopping` makes a
|
|
179
|
+
// repeat signal a no-op rather than a second `abort()`/log line (#2872).
|
|
180
|
+
let stopping = false;
|
|
171
181
|
const onSigint = () => {
|
|
182
|
+
if (stopping) return;
|
|
183
|
+
stopping = true;
|
|
172
184
|
console.error(formatWarning({ message: "interrupted — stopping operator" }));
|
|
173
185
|
controller.abort();
|
|
174
186
|
};
|
|
175
|
-
process.
|
|
187
|
+
process.on("SIGINT", onSigint);
|
|
176
188
|
|
|
177
189
|
const printRound = (events: OperatorTickEvent[]) => {
|
|
178
190
|
for (const event of events) console.error(formatInfo(formatRoundLine(event)));
|
|
@@ -268,30 +280,54 @@ export async function runStewardOperator(ctx: CommandContext): Promise<number> {
|
|
|
268
280
|
}
|
|
269
281
|
|
|
270
282
|
const controller = new AbortController();
|
|
283
|
+
// `process.on`, not `.once` (#2872): a supervisor that signals both a
|
|
284
|
+
// process group and a child sends a second SIGTERM close behind the
|
|
285
|
+
// first. A `.once` listener has already unregistered itself by then, so
|
|
286
|
+
// that second signal takes Node's default disposition and ends the
|
|
287
|
+
// process immediately — before the `finally` below releases the
|
|
288
|
+
// steward's lease. Kept registered for the whole shutdown and removed
|
|
289
|
+
// only once it's over, so a second, third or later signal is a no-op
|
|
290
|
+
// (`stopping` guards against re-aborting or re-logging) rather than a
|
|
291
|
+
// fast exit that skips the release.
|
|
292
|
+
let stopping = false;
|
|
271
293
|
const onSigint = () => {
|
|
294
|
+
if (stopping) return;
|
|
295
|
+
stopping = true;
|
|
272
296
|
console.error(formatWarning({ message: "interrupted — stopping steward" }));
|
|
273
297
|
controller.abort();
|
|
274
298
|
};
|
|
275
|
-
process.
|
|
276
|
-
process.
|
|
299
|
+
process.on("SIGINT", onSigint);
|
|
300
|
+
process.on("SIGTERM", onSigint);
|
|
277
301
|
|
|
278
302
|
const printRound = (events: OperatorTickEvent[]) => {
|
|
279
303
|
for (const event of events) console.error(formatInfo(formatRoundLine(event)));
|
|
280
304
|
};
|
|
281
305
|
|
|
282
|
-
const
|
|
306
|
+
const turnOps = stewardTurnOps(steward);
|
|
307
|
+
const scheduled = turnOps.filter((op) => op.schedule !== undefined);
|
|
308
|
+
const beside = stewardBesideOf(steward).map((b) => b.op);
|
|
283
309
|
try {
|
|
284
310
|
if (ctx.args.once) {
|
|
285
|
-
|
|
311
|
+
// One round, and the runs it started beside the turns (#2861) to their end.
|
|
312
|
+
const besideState = createBesideState();
|
|
313
|
+
const events = await runOperatorRound({ steward, holder, leaseTtlMs, activities, profiles, signal: controller.signal, besideState, stewardEnv: env });
|
|
286
314
|
printRound(events);
|
|
287
|
-
|
|
315
|
+
await waitForBesideRuns(besideState);
|
|
316
|
+
const ended = besideState.ended.splice(0);
|
|
317
|
+
printRound(ended);
|
|
318
|
+
return events.some((e) => e.kind === "tick-failed" || e.kind === "steward-busy" || e.kind === "ready-failed") ||
|
|
319
|
+
ended.some((e) => e.kind === "beside-ended" && e.code !== 0 && e.code !== 3)
|
|
320
|
+
? 1
|
|
321
|
+
: 0;
|
|
288
322
|
}
|
|
289
323
|
console.error(formatInfo(
|
|
290
|
-
`chant operator: steward ${steward.name} (local) runs ${scheduled.length} scheduled Op(s) of ${
|
|
291
|
-
|
|
324
|
+
`chant operator: steward ${steward.name} (local) runs ${scheduled.length} scheduled Op(s) of ${turnOps.length} as its turns` +
|
|
325
|
+
(beside.length > 0 ? `, and ${beside.join(", ")} beside them` : "") +
|
|
326
|
+
`, checking every ${intervalMs}ms (Ctrl-C to stop)`,
|
|
292
327
|
));
|
|
293
328
|
await runOperatorForever({
|
|
294
329
|
steward,
|
|
330
|
+
stewardEnv: env,
|
|
295
331
|
holder,
|
|
296
332
|
intervalMs,
|
|
297
333
|
leaseTtlMs,
|
|
@@ -302,11 +338,17 @@ export async function runStewardOperator(ctx: CommandContext): Promise<number> {
|
|
|
302
338
|
});
|
|
303
339
|
return 0;
|
|
304
340
|
} finally {
|
|
305
|
-
|
|
306
|
-
|
|
341
|
+
// The listeners stay registered through this whole `finally`, not just
|
|
342
|
+
// the `try` above (#2872): the lease release below is itself async (two
|
|
343
|
+
// more git plumbing calls), and a repeat signal landing in that window
|
|
344
|
+
// is exactly the shape the issue reported — removing the listeners
|
|
345
|
+
// first, before awaiting the release, would reopen the same hole one
|
|
346
|
+
// step later. `stopping` (set already) makes every repeat here a no-op.
|
|
307
347
|
// A courtesy: the lease expires on its own if this never runs.
|
|
308
348
|
const { record } = await readLease(stewardLeaseName(steward.name)).catch(() => ({ record: undefined }));
|
|
309
349
|
if (record?.holder === holder) await releaseLease(stewardLeaseName(steward.name), holder, record.token).catch(() => false);
|
|
350
|
+
process.removeListener("SIGINT", onSigint);
|
|
351
|
+
process.removeListener("SIGTERM", onSigint);
|
|
310
352
|
}
|
|
311
353
|
}
|
|
312
354
|
|
|
@@ -11,6 +11,7 @@ const discoverOpsMock = vi.fn();
|
|
|
11
11
|
// default is what every other call sees.
|
|
12
12
|
const discoverStewardsMock = vi.fn().mockResolvedValue({ stewards: new Map(), errors: [], conflicts: [] });
|
|
13
13
|
const acquireStewardTurnMock = vi.fn();
|
|
14
|
+
const holdBesideLeaseMock = vi.fn();
|
|
14
15
|
const releaseLeaseMock = vi.fn();
|
|
15
16
|
const loadChantConfigMock = vi.fn();
|
|
16
17
|
const writeFileSyncMock = vi.fn();
|
|
@@ -33,6 +34,10 @@ vi.mock("../../op/operator", async (importOriginal) => {
|
|
|
33
34
|
const actual = await importOriginal<typeof import("../../op/operator")>();
|
|
34
35
|
return { ...actual, acquireStewardTurn: (...args: unknown[]) => acquireStewardTurnMock(...args) };
|
|
35
36
|
});
|
|
37
|
+
vi.mock("../../op/steward-beside", async (importOriginal) => {
|
|
38
|
+
const actual = await importOriginal<typeof import("../../op/steward-beside")>();
|
|
39
|
+
return { ...actual, holdBesideLease: (...args: unknown[]) => holdBesideLeaseMock(...args) };
|
|
40
|
+
});
|
|
36
41
|
vi.mock("../../lifecycle/lease", async (importOriginal) => {
|
|
37
42
|
const actual = await importOriginal<typeof import("../../lifecycle/lease")>();
|
|
38
43
|
return { ...actual, releaseLease: (...args: unknown[]) => releaseLeaseMock(...args) };
|
|
@@ -438,6 +443,72 @@ describe("runOp: a steward's turn (#2750)", () => {
|
|
|
438
443
|
});
|
|
439
444
|
});
|
|
440
445
|
|
|
446
|
+
/**
|
|
447
|
+
* chant #2861: `chant run <op>` for an Op a steward runs beside its turns
|
|
448
|
+
* takes the Op's own lease, as `--holder`, and never the steward's turn.
|
|
449
|
+
* `holdBesideLease` is stubbed; `op/steward-beside.test.ts` runs it against a
|
|
450
|
+
* real repo.
|
|
451
|
+
*/
|
|
452
|
+
describe("runOp: an Op beside a steward's turns (#2861)", () => {
|
|
453
|
+
function stewardBeside(opName: string, name: string) {
|
|
454
|
+
return {
|
|
455
|
+
stewards: new Map([[name, {
|
|
456
|
+
declaration: {
|
|
457
|
+
kind: "Chant::Steward", name, ops: [{ name: "converge", phases: [] }, { name: opName, phases: [] }],
|
|
458
|
+
beside: [{ op: opName, ready: null }],
|
|
459
|
+
form: { default: "local", environments: {} }, capabilities: [], vault: null,
|
|
460
|
+
},
|
|
461
|
+
filePath: "ops/steward.op.ts", exportName: "steward",
|
|
462
|
+
}]]),
|
|
463
|
+
errors: [], conflicts: [],
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
beforeEach(() => {
|
|
468
|
+
discoverOpsMock.mockReset();
|
|
469
|
+
loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
|
|
470
|
+
loadPluginsMock.mockReset().mockResolvedValue([]);
|
|
471
|
+
acquireStewardTurnMock.mockReset();
|
|
472
|
+
holdBesideLeaseMock.mockReset();
|
|
473
|
+
releaseLeaseMock.mockReset().mockResolvedValue(true);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("takes the Op's lease as --holder, not the turn, runs, and releases it once the run is over", async () => {
|
|
477
|
+
discoverOpsMock.mockResolvedValue({
|
|
478
|
+
ops: new Map([localOp("dispatch", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
479
|
+
errors: [],
|
|
480
|
+
});
|
|
481
|
+
discoverStewardsMock.mockResolvedValueOnce(stewardBeside("dispatch", "box-steward"));
|
|
482
|
+
const release = vi.fn(async () => {});
|
|
483
|
+
holdBesideLeaseMock.mockResolvedValue({ acquired: true, lease: { op: "dispatch", holder: "h", token: "t1", acquiredAt: "x", expiresAt: "y" }, release });
|
|
484
|
+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
485
|
+
const exit = await runOp({ args: makeArgs({ path: "dispatch", holder: "box-steward/dispatch@op1" }), plugins: [], serializers: [] });
|
|
486
|
+
stderrWrite.mockRestore();
|
|
487
|
+
|
|
488
|
+
expect(exit).toBe(0);
|
|
489
|
+
expect(acquireStewardTurnMock).not.toHaveBeenCalled();
|
|
490
|
+
expect(holdBesideLeaseMock).toHaveBeenCalledWith("dispatch", "box-steward/dispatch@op1");
|
|
491
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("a run of it in progress: refused, naming the holder, nothing runs", async () => {
|
|
495
|
+
discoverOpsMock.mockResolvedValue({
|
|
496
|
+
ops: new Map([localOp("dispatch", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
497
|
+
errors: [],
|
|
498
|
+
});
|
|
499
|
+
discoverStewardsMock.mockResolvedValueOnce(stewardBeside("dispatch", "box-steward"));
|
|
500
|
+
holdBesideLeaseMock.mockResolvedValue({ acquired: false, heldBy: "box-steward/dispatch@op1" });
|
|
501
|
+
const stderr = makeStderrSpy();
|
|
502
|
+
const exit = await runOp({ args: makeArgs({ path: "dispatch" }), plugins: [], serializers: [] });
|
|
503
|
+
|
|
504
|
+
expect(exit).toBe(1);
|
|
505
|
+
const out = stderr.join("\n");
|
|
506
|
+
expect(out).toContain(`runs beside steward "box-steward"'s turns`);
|
|
507
|
+
expect(out).toContain("box-steward/dispatch@op1");
|
|
508
|
+
expect(acquireStewardTurnMock).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
441
512
|
/**
|
|
442
513
|
* chant #2243 — a gated run's exit code is the one thing `--gated-exit`
|
|
443
514
|
* remaps, and the gate is reported where CI can see it without opening a log.
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -17,7 +17,8 @@ import { recordGateApproval } from "./operator";
|
|
|
17
17
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
18
18
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
19
19
|
import type { CommandContext } from "../registry";
|
|
20
|
-
import { stewardFormFor, stewardTurnLeaseName, DEFAULT_STEWARD_ENV, type StewardDeclaration } from "../../op/steward";
|
|
20
|
+
import { stewardBesideFor, stewardFormFor, stewardTurnLeaseName, DEFAULT_STEWARD_ENV, type StewardDeclaration } from "../../op/steward";
|
|
21
|
+
import { holdBesideLease } from "../../op/steward-beside";
|
|
21
22
|
import { acquireStewardTurn, STEWARD_TURN_WAIT_MS } from "../../op/operator";
|
|
22
23
|
import { releaseLease, currentHolderId, type AcquireLeaseResult } from "../../lifecycle/lease";
|
|
23
24
|
import { StaleLockError } from "../../lifecycle/git";
|
|
@@ -557,6 +558,12 @@ type StewardTurnGate =
|
|
|
557
558
|
* hand. Ctrl-C already means "abort my own run" here; it must not also mean
|
|
558
559
|
* "give up waiting for someone else's turn".
|
|
559
560
|
*
|
|
561
|
+
* An Op the steward runs beside its turns (#2861) takes the Op's own lease
|
|
562
|
+
* instead, renewed for as long as the run lasts, and never the turn: the run
|
|
563
|
+
* is one at a time with the runs the operator starts of it, and holds none
|
|
564
|
+
* of the steward's other Ops up. Held already, it refuses at once, naming
|
|
565
|
+
* the holder.
|
|
566
|
+
*
|
|
560
567
|
* `undefined` (via the caller checking `owner`) when no steward lists this
|
|
561
568
|
* op — the ordinary, untouched path.
|
|
562
569
|
*/
|
|
@@ -585,6 +592,8 @@ async function stewardTurnGate(opName: string, ctx: CommandContext): Promise<Ste
|
|
|
585
592
|
};
|
|
586
593
|
}
|
|
587
594
|
|
|
595
|
+
if (stewardBesideFor(owner, opName)) return besideLeaseGate(opName, owner.name, ctx);
|
|
596
|
+
|
|
588
597
|
const holder = currentHolderId();
|
|
589
598
|
let turn: AcquireLeaseResult;
|
|
590
599
|
try {
|
|
@@ -619,6 +628,36 @@ async function stewardTurnGate(opName: string, ctx: CommandContext): Promise<Ste
|
|
|
619
628
|
return { ok: true, release: async () => { await releaseLease(stewardTurnLeaseName(stewardName), holder, lease.token).catch(() => false); } };
|
|
620
629
|
}
|
|
621
630
|
|
|
631
|
+
/**
|
|
632
|
+
* The lease a `chant run` of an Op beside a steward's turns holds (#2861):
|
|
633
|
+
* the Op's own, as `--holder` (the operator passes `<steward>/<op>@<its
|
|
634
|
+
* holder>`) or this process.
|
|
635
|
+
*/
|
|
636
|
+
async function besideLeaseGate(opName: string, steward: string, ctx: CommandContext): Promise<StewardTurnGate> {
|
|
637
|
+
const holder = ctx.args.holder ?? currentHolderId();
|
|
638
|
+
let held;
|
|
639
|
+
try {
|
|
640
|
+
held = await holdBesideLease(opName, holder);
|
|
641
|
+
} catch (err) {
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
message: `Op "${opName}" runs beside steward "${steward}"'s turns, and its lease could not be read`,
|
|
645
|
+
hint:
|
|
646
|
+
`${err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err)} ` +
|
|
647
|
+
`This can happen when a process was killed mid-write; check refs/chant/lease/${opName} for a stale lock.`,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (!held.acquired) {
|
|
651
|
+
return {
|
|
652
|
+
ok: false,
|
|
653
|
+
message: `Op "${opName}" runs beside steward "${steward}"'s turns, one run at a time, and a run of it is in progress` +
|
|
654
|
+
(held.heldBy ? ` (held by ${held.heldBy})` : ""),
|
|
655
|
+
hint: `Wait for that run to end and re-run \`chant run ${opName}\`; \`chant workspace status --json\` lists it under the steward. Its lease expires on its own if the process holding it died.`,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return { ok: true, release: held.release };
|
|
659
|
+
}
|
|
660
|
+
|
|
622
661
|
export async function runOp(ctx: CommandContext): Promise<number> {
|
|
623
662
|
if (ctx.args.generate) {
|
|
624
663
|
if (ctx.args.components) {
|
|
@@ -975,20 +1014,35 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
975
1014
|
// runs under; it is taken where the run executes, which for a hosted
|
|
976
1015
|
// runtime is not this process.
|
|
977
1016
|
const work = await resolveRunWork(ctx, config, runtime.name);
|
|
978
|
-
if (work === null)
|
|
1017
|
+
if (work === null) {
|
|
1018
|
+
await stewardGate?.release?.();
|
|
1019
|
+
return 1;
|
|
1020
|
+
}
|
|
979
1021
|
|
|
980
1022
|
// `--progress-json` streams one NDJSON StepRecord per settled step, fed by
|
|
981
1023
|
// whatever the runtime reports through `progress`.
|
|
982
1024
|
const progress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
|
|
983
1025
|
|
|
984
|
-
// Ctrl-C
|
|
985
|
-
//
|
|
1026
|
+
// Ctrl-C, or a SIGTERM (the signal a steward's operator sends this process
|
|
1027
|
+
// when it runs beside the steward's turns and the operator itself stops,
|
|
1028
|
+
// and what a plain `kill <pid>` sends with no signal named), aborts
|
|
1029
|
+
// in-flight activities (kills their child processes) instead of orphaning
|
|
1030
|
+
// them. `process.on`, not `.once` (chant#2872's shape, here too): a second
|
|
1031
|
+
// signal arriving before the `finally` below has released this run's lease
|
|
1032
|
+
// (`stewardGate`, for a beside run) must not fall through to Node's default
|
|
1033
|
+
// disposition and skip that release. `stopping` makes a repeat a no-op; the
|
|
1034
|
+
// listeners are removed only after the release, at the very end of
|
|
1035
|
+
// `finally`, not before it.
|
|
986
1036
|
const controller = new AbortController();
|
|
1037
|
+
let stopping = false;
|
|
987
1038
|
const onSigint = () => {
|
|
1039
|
+
if (stopping) return;
|
|
1040
|
+
stopping = true;
|
|
988
1041
|
console.error(formatWarning({ message: "interrupted — stopping Op" }));
|
|
989
1042
|
controller.abort();
|
|
990
1043
|
};
|
|
991
|
-
process.
|
|
1044
|
+
process.on("SIGINT", onSigint);
|
|
1045
|
+
process.on("SIGTERM", onSigint);
|
|
992
1046
|
|
|
993
1047
|
try {
|
|
994
1048
|
const handle = await runtime.start(config, {
|
|
@@ -1065,10 +1119,14 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1065
1119
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
1066
1120
|
return 1;
|
|
1067
1121
|
} finally {
|
|
1068
|
-
process.removeListener("SIGINT", onSigint);
|
|
1069
1122
|
// The turn is over, win or lose — free it so the steward's next round,
|
|
1070
|
-
// or the next hand run, doesn't wait out this run's own TTL (#2750).
|
|
1123
|
+
// or the next hand run, doesn't wait out this run's own TTL (#2750). The
|
|
1124
|
+
// listeners stay registered until after this release (chant#2872): it is
|
|
1125
|
+
// itself async, and removing them first would let a repeat signal in
|
|
1126
|
+
// that window fall through to Node's default disposition and skip it.
|
|
1071
1127
|
await stewardGate?.release?.();
|
|
1128
|
+
process.removeListener("SIGINT", onSigint);
|
|
1129
|
+
process.removeListener("SIGTERM", onSigint);
|
|
1072
1130
|
}
|
|
1073
1131
|
}
|
|
1074
1132
|
|
package/src/cli/main.ts
CHANGED
|
@@ -90,6 +90,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
90
90
|
"--no-release-record",
|
|
91
91
|
"--fold",
|
|
92
92
|
"--no-fold",
|
|
93
|
+
"--no-cache",
|
|
93
94
|
"--sandbox",
|
|
94
95
|
"--yes",
|
|
95
96
|
"--confirm-prod",
|
|
@@ -481,6 +482,9 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
481
482
|
result.noReleaseRecord = true;
|
|
482
483
|
} else if (arg === "--fold") {
|
|
483
484
|
result.fold = true;
|
|
485
|
+
} else if (arg === "--no-cache") {
|
|
486
|
+
// `chant workspace graph --no-cache` (#2876): read every member.
|
|
487
|
+
result.noCache = true;
|
|
484
488
|
} else if (arg === "--no-fold") {
|
|
485
489
|
// chant #1134 — fold is the default build path; this is the explicit
|
|
486
490
|
// opt-out, and like --fold it beats chant.config.ts's build.fold.
|
|
@@ -748,7 +752,9 @@ Ops:
|
|
|
748
752
|
CronJob invokers use this instead of the daemon).
|
|
749
753
|
--steward [<name>] runs a declared steward's local
|
|
750
754
|
form instead (#2731): its scheduled Ops on their
|
|
751
|
-
crons, under the steward's own lease
|
|
755
|
+
crons, under the steward's own lease, and its
|
|
756
|
+
beside Ops as chant run processes of their own
|
|
757
|
+
(#2861)
|
|
752
758
|
operator status Last tick, outcomes, and pending gates per
|
|
753
759
|
ConvergeOp, read from the chant/lifecycle orphan
|
|
754
760
|
branch alone — no daemon needs to be running
|
|
@@ -841,10 +847,12 @@ Workspace (level 1, #2524):
|
|
|
841
847
|
Set top-level fields of one record. A closed record
|
|
842
848
|
never changes, and an approved one changes only its
|
|
843
849
|
state (upward), pins and reviews; anything else is
|
|
844
|
-
refused with amend-supersede-instead.
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
850
|
+
refused with amend-supersede-instead. Moving a
|
|
851
|
+
record to its kind's ratified state (ratified for
|
|
852
|
+
decisions) is refused with ratify-quorum-not-met
|
|
853
|
+
until its quorum is met. --sign seals the author
|
|
854
|
+
again; without it an amendment removes the author
|
|
855
|
+
seal and says so. Prints {path, id, changed}
|
|
848
856
|
workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]
|
|
849
857
|
Append a review to one record, dated and bound to
|
|
850
858
|
the digest of the record text. A dissent needs
|
|
@@ -931,7 +939,10 @@ Workspace (level 1, #2524):
|
|
|
931
939
|
with <member>/<id> ids and groups.byMember: the
|
|
932
940
|
read-contract document. --at <rev> runs each member's
|
|
933
941
|
source as it was at that commit; --kind adds the
|
|
934
|
-
records' asset and constrains links
|
|
942
|
+
records' asset and constrains links. A member whose
|
|
943
|
+
source and toolchain are unchanged is served from
|
|
944
|
+
the cache in $CHANT_CACHE_DIR or ~/.cache/chant;
|
|
945
|
+
--no-cache reads every member
|
|
935
946
|
workspace graph --composites [--at <rev>] [--member <name>] [-o <file>]
|
|
936
947
|
Each composite instance the members declare, with the
|
|
937
948
|
components whose contract can deploy it; an instance
|
|
@@ -108,6 +108,20 @@ describe("McpServer", () => {
|
|
|
108
108
|
expect(result.protocolVersion).toBe("2026-07-28");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
// Claude Code 2.1.274 asks for 2025-11-25 and accepts only these back.
|
|
112
|
+
for (const version of ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]) {
|
|
113
|
+
test(`a Claude Code client asking for ${version} on initialize gets ${version} back`, async () => {
|
|
114
|
+
const response = await server.handleRequest({
|
|
115
|
+
jsonrpc: "2.0",
|
|
116
|
+
id: 1,
|
|
117
|
+
method: "initialize",
|
|
118
|
+
params: { protocolVersion: version, capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.274" } },
|
|
119
|
+
});
|
|
120
|
+
expect(response.error).toBeUndefined();
|
|
121
|
+
expect((response.result as Record<string, unknown>).protocolVersion).toBe(version);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
test("an unrecognized requested version falls back to the latest supported", async () => {
|
|
112
126
|
const response = await server.handleRequest({
|
|
113
127
|
jsonrpc: "2.0",
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -25,15 +25,21 @@ import { CHANT_VERSION } from "../version";
|
|
|
25
25
|
* Exported because `docs-parity.test.ts` reads it: `cli/mcp.mdx` states these
|
|
26
26
|
* revisions in prose, and stating them twice is how the page came to claim
|
|
27
27
|
* 2024-11-05 for two releases after this list moved past it (#2385).
|
|
28
|
+
*
|
|
29
|
+
* The 2025 revisions are here because Claude Code 2.1.274 asks for
|
|
30
|
+
* 2025-11-25 and rejects any answer outside 2025-11-25, 2025-06-18,
|
|
31
|
+
* 2025-03-26, 2024-11-05 and 2024-10-07. A 2025 client sends
|
|
32
|
+
* `protocolVersion`/`clientInfo` at the top level of `initialize`, which
|
|
33
|
+
* {@link parseMeta} already falls back to, so it needs no other branch.
|
|
28
34
|
*/
|
|
29
|
-
export const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
|
|
35
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const;
|
|
30
36
|
const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
39
|
* Pick the protocol version to answer with: the client's requested version
|
|
34
|
-
* when we support it, otherwise our latest. A 2024-11-05 client that
|
|
35
|
-
* for
|
|
36
|
-
* that never says
|
|
40
|
+
* when we support it, otherwise our latest. A 2024-11-05 or 2025 client that
|
|
41
|
+
* asks for its own version gets it back unchanged; a 2026-07-28 client, or one
|
|
42
|
+
* that never says, gets the latest revision (#1194).
|
|
37
43
|
*/
|
|
38
44
|
export function negotiateProtocolVersion(requested: string | undefined): string {
|
|
39
45
|
if (requested && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested)) {
|
|
@@ -195,7 +195,7 @@ export const workspaceWriteTools: ToolDefinition[] = [
|
|
|
195
195
|
{
|
|
196
196
|
name: "records-amend",
|
|
197
197
|
description:
|
|
198
|
-
"Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A source block given in the fields records that the change came through MCP. " +
|
|
198
|
+
"Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A record moves to its kind's ratified state only once its review quorum is met (ratify-quorum-not-met). A source block given in the fields records that the change came through MCP. " +
|
|
199
199
|
PROTOCOL,
|
|
200
200
|
inputSchema: {
|
|
201
201
|
type: "object",
|
package/src/cli/registry.ts
CHANGED
|
@@ -299,6 +299,8 @@ export interface ParsedArgs {
|
|
|
299
299
|
subject?: string;
|
|
300
300
|
/** `chant workspace points answer <id> --answer <value>` (#2739): the people's answer. */
|
|
301
301
|
answer?: string;
|
|
302
|
+
/** `chant workspace graph --no-cache` (#2876): read every member, bypassing the per-member cache. */
|
|
303
|
+
noCache?: boolean;
|
|
302
304
|
/** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
|
|
303
305
|
composites?: boolean;
|
|
304
306
|
/** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
|
|
@@ -72,7 +72,9 @@ afterEach(() => {
|
|
|
72
72
|
function chant(args: string[], cwd: string): string {
|
|
73
73
|
const result = spawnSync(process.execPath, ["--import", tsxLoader, mainTs, ...args], {
|
|
74
74
|
cwd,
|
|
75
|
-
|
|
75
|
+
// TMPDIR passes through so tsx caches under the run's temp directory
|
|
76
|
+
// (chant#2864), not the machine-wide default this scrubbed env would give.
|
|
77
|
+
env: { PATH: process.env.PATH ?? "", HOME: home, TMPDIR: tmpdir() },
|
|
76
78
|
encoding: "utf-8",
|
|
77
79
|
input: "n\n",
|
|
78
80
|
timeout: 60_000,
|
|
@@ -105,7 +107,11 @@ describe("commands that read path lexicons statically run no project code", () =
|
|
|
105
107
|
|
|
106
108
|
test("a config it cannot read is reported, and still not run", () => {
|
|
107
109
|
writeConfig(`process.env.CI ? ["github"] : [{ name: "github", module: "./gh/index.ts" }]`);
|
|
108
|
-
|
|
110
|
+
// `import --agents` rather than `dev onboard`: with the config unread,
|
|
111
|
+
// github is treated as a package, and onboard then patches the chant
|
|
112
|
+
// repo it runs from (its CI workflow, chant#2863). Both commands print
|
|
113
|
+
// the notice from the same recordProjectLexicons read.
|
|
114
|
+
const output = chant(["import", "--agents", "--lexicon", "github", "--output", join(root, "out")], project);
|
|
109
115
|
expect(output).toContain("could not read the lexicons in");
|
|
110
116
|
expect(output).toContain("treated as a package");
|
|
111
117
|
expect(existsSync(marker)).toBe(false);
|
|
@@ -27,11 +27,28 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
*/
|
|
28
28
|
const SRC = fileURLToPath(new URL("../", import.meta.url));
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Run `read`, or return undefined when the path is gone. Other test files
|
|
32
|
+
* create and delete fixtures under src while this one walks it
|
|
33
|
+
* (lint/config.test.ts's `__test_config__`, chant#2870), so an entry listed a
|
|
34
|
+
* moment ago may no longer exist.
|
|
35
|
+
*/
|
|
36
|
+
function unlessGone<T>(read: () => T): T | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return read();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/** Every TypeScript file under core's src, tests and fixtures included. */
|
|
31
46
|
function sourceFiles(dir: string): string[] {
|
|
32
|
-
return readdirSync(dir).flatMap((entry) => {
|
|
47
|
+
return (unlessGone(() => readdirSync(dir)) ?? []).flatMap((entry) => {
|
|
33
48
|
const path = join(dir, entry);
|
|
34
|
-
|
|
49
|
+
const stat = unlessGone(() => statSync(path));
|
|
50
|
+
if (stat === undefined) return [];
|
|
51
|
+
if (stat.isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path);
|
|
35
52
|
return /\.(ts|mts|cts)$/.test(entry) ? [path] : [];
|
|
36
53
|
});
|
|
37
54
|
}
|
|
@@ -40,7 +57,8 @@ describe("source files are text, not binary (chant#2280)", () => {
|
|
|
40
57
|
test("no shipped source file contains a NUL byte", () => {
|
|
41
58
|
const offenders: string[] = [];
|
|
42
59
|
for (const file of sourceFiles(SRC)) {
|
|
43
|
-
const buf = readFileSync(file);
|
|
60
|
+
const buf = unlessGone(() => readFileSync(file));
|
|
61
|
+
if (buf === undefined) continue;
|
|
44
62
|
const at = buf.indexOf(0);
|
|
45
63
|
if (at !== -1) offenders.push(`${relative(SRC, file)} (first at byte ${at})`);
|
|
46
64
|
}
|
package/src/okf.test.ts
CHANGED
|
@@ -155,7 +155,12 @@ const exampleCases: Array<[string, string]> = [
|
|
|
155
155
|
|
|
156
156
|
describe("buildOkfBundle over shipped examples", () => {
|
|
157
157
|
for (const [name, rel] of exampleCases) {
|
|
158
|
-
test
|
|
158
|
+
// The test's own `expect` names the snapshot after this test. The global
|
|
159
|
+
// one names it after whichever test is current, so when a slow discover
|
|
160
|
+
// outlives its timeout and the next case starts, a late call records
|
|
161
|
+
// under the next case's name and the checkout gains a "... 2" snapshot
|
|
162
|
+
// (chant#2863).
|
|
163
|
+
test(`${name} emits a conformant, snapshot-stable bundle`, async ({ expect }) => {
|
|
159
164
|
const projectPath = resolve(repoRoot, rel);
|
|
160
165
|
const result = await discover(projectPath);
|
|
161
166
|
expect(result.errors).toEqual([]);
|