@intentius/chant 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +2 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/op/builders.d.ts +14 -3
  7. package/dist/op/builders.d.ts.map +1 -1
  8. package/dist/op/index.d.ts +6 -3
  9. package/dist/op/index.d.ts.map +1 -1
  10. package/dist/op/operator.d.ts +90 -0
  11. package/dist/op/operator.d.ts.map +1 -1
  12. package/dist/op/steward-beside.d.ts +84 -0
  13. package/dist/op/steward-beside.d.ts.map +1 -0
  14. package/dist/op/steward.d.ts +87 -2
  15. package/dist/op/steward.d.ts.map +1 -1
  16. package/dist/workspace/box-services.d.ts +31 -0
  17. package/dist/workspace/box-services.d.ts.map +1 -0
  18. package/dist/workspace/compose-graph.d.ts +11 -0
  19. package/dist/workspace/compose-graph.d.ts.map +1 -1
  20. package/dist/workspace/composites.d.ts +5 -1
  21. package/dist/workspace/composites.d.ts.map +1 -1
  22. package/dist/workspace/declaration.d.ts +37 -0
  23. package/dist/workspace/declaration.d.ts.map +1 -1
  24. package/dist/workspace/declaration.schema.json +57 -1
  25. package/dist/workspace/graph-cache.d.ts +168 -0
  26. package/dist/workspace/graph-cache.d.ts.map +1 -0
  27. package/dist/workspace/graph-cli.d.ts +11 -5
  28. package/dist/workspace/graph-cli.d.ts.map +1 -1
  29. package/dist/workspace/kind-readers.d.ts +39 -0
  30. package/dist/workspace/kind-readers.d.ts.map +1 -0
  31. package/dist/workspace/kinds.d.ts +29 -0
  32. package/dist/workspace/kinds.d.ts.map +1 -1
  33. package/dist/workspace/member-commands.d.ts +15 -1
  34. package/dist/workspace/member-commands.d.ts.map +1 -1
  35. package/dist/workspace/member-run.d.ts +2 -0
  36. package/dist/workspace/member-run.d.ts.map +1 -1
  37. package/dist/workspace/reason-codes.d.ts +3 -1
  38. package/dist/workspace/reason-codes.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +10 -1
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-write.d.ts +4 -2
  42. package/dist/workspace/records-write.d.ts.map +1 -1
  43. package/dist/workspace/records.d.ts +8 -3
  44. package/dist/workspace/records.d.ts.map +1 -1
  45. package/dist/workspace/status-stewards.d.ts +23 -7
  46. package/dist/workspace/status-stewards.d.ts.map +1 -1
  47. package/dist/workspace/status.d.ts +12 -0
  48. package/dist/workspace/status.d.ts.map +1 -1
  49. package/dist/workspace/work-evidence.d.ts +1 -1
  50. package/dist/workspace/work-evidence.d.ts.map +1 -1
  51. package/dist/workspace/workspace-kinds.schema.json +26 -0
  52. package/package.json +1 -1
  53. package/src/cli/commands/carve-bridge.test.ts +7 -3
  54. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  55. package/src/cli/handlers/operator.ts +53 -11
  56. package/src/cli/handlers/run.test.ts +71 -0
  57. package/src/cli/handlers/run.ts +65 -7
  58. package/src/cli/main.ts +17 -6
  59. package/src/cli/mcp/workspace-tools.ts +1 -1
  60. package/src/cli/registry.ts +2 -0
  61. package/src/cli/static-config-read.test.ts +8 -2
  62. package/src/meta/source-is-text.test.ts +21 -3
  63. package/src/okf.test.ts +6 -1
  64. package/src/op/builders.ts +14 -3
  65. package/src/op/index.ts +8 -2
  66. package/src/op/operator.ts +264 -16
  67. package/src/op/steward-beside.test.ts +267 -0
  68. package/src/op/steward-beside.ts +219 -0
  69. package/src/op/steward-points.test.ts +61 -1
  70. package/src/op/steward.ts +135 -3
  71. package/src/workspace/box-services.test.ts +129 -0
  72. package/src/workspace/box-services.ts +51 -0
  73. package/src/workspace/checks/boxes.test.ts +1 -0
  74. package/src/workspace/compose-graph.test.ts +1 -0
  75. package/src/workspace/compose-graph.ts +11 -0
  76. package/src/workspace/composites.test.ts +1 -1
  77. package/src/workspace/composites.ts +12 -5
  78. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  79. package/src/workspace/declaration.schema.json +57 -1
  80. package/src/workspace/declaration.ts +104 -0
  81. package/src/workspace/graph-cache.test.ts +343 -0
  82. package/src/workspace/graph-cache.ts +409 -0
  83. package/src/workspace/graph-cli.ts +93 -26
  84. package/src/workspace/graph-contract.test.ts +129 -6
  85. package/src/workspace/graph.schema.json +21 -1
  86. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  87. package/src/workspace/kind-readers.test.ts +134 -0
  88. package/src/workspace/kind-readers.ts +111 -0
  89. package/src/workspace/kinds.test.ts +49 -0
  90. package/src/workspace/kinds.ts +59 -2
  91. package/src/workspace/member-commands.test.ts +15 -0
  92. package/src/workspace/member-commands.ts +47 -7
  93. package/src/workspace/member-run.ts +10 -2
  94. package/src/workspace/reason-codes.ts +3 -1
  95. package/src/workspace/records-amend.schema.json +1 -0
  96. package/src/workspace/records-cli.ts +20 -12
  97. package/src/workspace/records-contract.test.ts +2 -1
  98. package/src/workspace/records-new.schema.json +1 -0
  99. package/src/workspace/records-quorum.test.ts +10 -3
  100. package/src/workspace/records-sessions-write.test.ts +2 -1
  101. package/src/workspace/records-write.test.ts +101 -1
  102. package/src/workspace/records-write.ts +50 -6
  103. package/src/workspace/records.ts +22 -5
  104. package/src/workspace/status-contract.test.ts +31 -1
  105. package/src/workspace/status-stewards.ts +39 -6
  106. package/src/workspace/status.schema.json +49 -4
  107. package/src/workspace/status.ts +22 -0
  108. package/src/workspace/trust/record-seal.test.ts +26 -2
  109. package/src/workspace/work-evidence.schema.json +1 -0
  110. 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.once("SIGINT", onSigint);
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.once("SIGINT", onSigint);
276
- process.once("SIGTERM", onSigint);
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 scheduled = steward.ops.filter((op) => op.schedule !== undefined);
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
- const events = await runOperatorRound({ steward, holder, leaseTtlMs, activities, profiles, signal: controller.signal });
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
- return events.some((e) => e.kind === "tick-failed" || e.kind === "steward-busy") ? 1 : 0;
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 ${steward.ops.length}, ` +
291
- `checking every ${intervalMs}ms (Ctrl-C to stop)`,
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
- process.removeListener("SIGINT", onSigint);
306
- process.removeListener("SIGTERM", onSigint);
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.
@@ -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) return 1;
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 aborts in-flight activities (kills their child processes) instead of
985
- // orphaning them. The handler is removed in `finally` so it never leaks.
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.once("SIGINT", onSigint);
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. --sign seals
845
- the author again; without it an amendment removes
846
- the author seal and says so. Prints
847
- {path, id, changed}
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
@@ -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",
@@ -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
- env: { PATH: process.env.PATH ?? "", HOME: home },
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
- const output = chant(["dev", "onboard", "github"], project);
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
- if (statSync(path).isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path);
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(`${name} emits a conformant, snapshot-stable bundle`, async () => {
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([]);
@@ -916,10 +916,21 @@ export const spriteApplyNetworkPolicy = (args: {
916
916
  return activity("spriteApplyNetworkPolicy", rest, profile ?? "fastIdempotent");
917
917
  };
918
918
 
919
- /** Reconcile a sprite's background services (create-or-update, optionally start). Defaults to the `fastIdempotent` profile (override via `profile`). */
919
+ /**
920
+ * Reconcile a sprite's background services (create-or-update, optionally
921
+ * start). With an `id`, through the Sprites API and its `services`; without
922
+ * one, inside the sprite through sprite-env, applying the box block's
923
+ * services (`box: true`, #2880): `only` names some of them, `start` starts
924
+ * the applied ones that are not running, `restart` restarts the converged
925
+ * ones. Defaults to the `fastIdempotent` profile (override via `profile`).
926
+ */
920
927
  export const spriteApplyServices = (args: {
921
- id: string;
922
- services: Array<{
928
+ id?: string;
929
+ box?: boolean;
930
+ only?: string[];
931
+ restart?: boolean;
932
+ spriteEnv?: string;
933
+ services?: Array<{
923
934
  name: string;
924
935
  cmd: string;
925
936
  args?: string[];
package/src/op/index.ts CHANGED
@@ -109,15 +109,21 @@ export type {
109
109
  export {
110
110
  discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
111
111
  formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS, acquireStewardLease,
112
- acquireStewardTurn, STEWARD_TURN_WAIT_MS,
112
+ acquireStewardTurn, STEWARD_TURN_WAIT_MS, createBesideState, waitForBesideRuns, stopBesideRuns,
113
113
  } from "./operator";
114
+ export type { BesideState } from "./operator";
115
+ export {
116
+ spawnBesideRun, inProcessBesideLauncher, holdBesideLease, askReady, DEFAULT_READY_TIMEOUT_MS,
117
+ } from "./steward-beside";
118
+ export type { BesideStart, BesideExit, BesideHandle, BesideLauncher, BesideWhy, HeldBesideLease, ReadyAnswer } from "./steward-beside";
114
119
  export {
115
120
  declareSteward, isStewardDeclaration, stewardFormFor, stewardOpConfig, normaliseStewardForm, stewardLeaseName,
116
- stewardTurnLeaseName,
121
+ stewardTurnLeaseName, stewardBesideOf, stewardBesideFor, stewardTurnOps, readinessKeys,
117
122
  STEWARD_KIND, STEWARD_FORMS, STEWARD_NAME_PATTERN, DEFAULT_STEWARD_ENV,
118
123
  } from "./steward";
119
124
  export type {
120
125
  StewardDeclaration, StewardDeclarationConfig, StewardForm, StewardFormSpec, StewardOpInput,
126
+ StewardBeside, StewardBesideInput,
121
127
  } from "./steward";
122
128
  export { discoverStewards } from "./discover";
123
129
  export {