@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
@@ -38,12 +38,13 @@
38
38
  import type { ActivityFn, ActivityProfile } from "./activity-registry";
39
39
  import { discoverOps, type DiscoveredOp } from "./discover";
40
40
  import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
41
- import { acquireLease, releaseLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
41
+ import { acquireLease, releaseLease, stillHoldsLease, currentHolderId, readLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
42
42
  import { randomUUID } from "node:crypto";
43
43
  import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
44
44
  import { readGateLedger, latestResolutionSince } from "../lifecycle/gate-ledger";
45
45
  import { enterStewardTurn } from "./steward-turn";
46
- import { stewardLeaseName, stewardTurnLeaseName, type StewardDeclaration } from "./steward";
46
+ import { stewardBesideOf, stewardLeaseName, stewardTurnLeaseName, stewardTurnOps, type StewardDeclaration } from "./steward";
47
+ import { askReady, spawnBesideRun, type BesideHandle, type BesideLauncher, type BesideWhy } from "./steward-beside";
47
48
  import { stewardWorkHolder } from "./work-lease-run";
48
49
  import { StaleLockError } from "../lifecycle/git";
49
50
  import { cronMatches, cronDueBetween } from "./cron";
@@ -146,7 +147,78 @@ export type OperatorTickEvent =
146
147
  * lease for the whole steward, not one per op). The next round tries
147
148
  * again.
148
149
  */
149
- | { kind: "turn-busy"; op: string; env: string; steward: string; heldBy?: string };
150
+ | { kind: "turn-busy"; op: string; env: string; steward: string; heldBy?: string }
151
+ /**
152
+ * A steward's Op beside its turns (#2861) was started in a process of its
153
+ * own, and the round went on without waiting for it. `why` is its cron, its
154
+ * ready step (`keys` are the work keys it named), or a waiting run to
155
+ * resume (`resumed`, the question now answered) or a gated one (`approved`).
156
+ * `holder` is the Op's lease holder and its work lease holder.
157
+ */
158
+ | {
159
+ kind: "beside-started";
160
+ op: string;
161
+ env: string;
162
+ why: BesideWhy;
163
+ holder: string;
164
+ keys?: string[];
165
+ resumed?: string;
166
+ approved?: { op: string; gate: string };
167
+ }
168
+ /** A run of an Op beside the turns is in flight (#2861): this operator's, or one whose lease another holder has, such as a hand `chant run`. Nothing was started. */
169
+ | { kind: "beside-running"; op: string; env: string; heldBy?: string }
170
+ /** A run this operator started beside the turns ended (#2861), reported on the round after. `code` is `chant run`'s exit code: 0 ok, 3 gated or waiting, 1 failed. */
171
+ | { kind: "beside-ended"; op: string; env: string; code: number | null; error?: string }
172
+ /**
173
+ * An Op beside the turns whose ready step names no work (#2861), or only
174
+ * work this operator already started a run for (`already`).
175
+ */
176
+ | { kind: "skipped-not-ready"; op: string; env: string; already?: string[] }
177
+ /** An Op beside the turns whose ready step failed, or answered in a shape it can't read (#2861). Nothing was started. */
178
+ | { kind: "ready-failed"; op: string; env: string; error: string };
179
+
180
+ /**
181
+ * What a local steward's operator keeps about the runs it starts beside its
182
+ * turns (#2861), across rounds: the ones in flight, the work keys a ready
183
+ * step named that it already started a run for, and the runs that ended since
184
+ * the last round, which the next round reports. `runOperatorForever` owns one
185
+ * for its life; a restarted operator starts empty and finds a run still in
186
+ * flight by its lease.
187
+ */
188
+ export interface BesideState {
189
+ running: Map<string, { handle: BesideHandle; holder: string; why: BesideWhy }>;
190
+ started: Map<string, Set<string>>;
191
+ ended: OperatorTickEvent[];
192
+ }
193
+
194
+ /** A fresh {@link BesideState}. */
195
+ export function createBesideState(): BesideState {
196
+ return { running: new Map(), started: new Map(), ended: [] };
197
+ }
198
+
199
+ /** Wait for every run in flight beside the turns to end. `chant operator --steward --once` does before it exits. */
200
+ export async function waitForBesideRuns(state: BesideState): Promise<void> {
201
+ await Promise.all([...state.running.values()].map((r) => r.handle.done));
202
+ }
203
+
204
+ /**
205
+ * Ask every run in flight beside the turns to stop, and wait up to `graceMs`
206
+ * for them to end. The operator does when it stops, so no run it started
207
+ * outlives it holding the Op's lease past its renewals.
208
+ */
209
+ export async function stopBesideRuns(state: BesideState, graceMs = 10_000): Promise<void> {
210
+ const runs = [...state.running.values()];
211
+ for (const r of runs) r.handle.stop();
212
+ let timer: ReturnType<typeof setTimeout> | undefined;
213
+ await Promise.race([
214
+ Promise.all(runs.map((r) => r.handle.done)),
215
+ new Promise<void>((resolve) => {
216
+ timer = setTimeout(resolve, graceMs);
217
+ timer.unref?.();
218
+ }),
219
+ ]);
220
+ if (timer) clearTimeout(timer);
221
+ }
150
222
 
151
223
  export interface OperatorRoundOptions {
152
224
  cwd?: string;
@@ -185,6 +257,16 @@ export interface OperatorRoundOptions {
185
257
  * read. Defaults to the workspace's `points` read; a test injects one.
186
258
  */
187
259
  readQuestions?: (cwd: string) => Promise<Map<string, string> | null>;
260
+ /**
261
+ * The runs a steward's operator started beside its turns (#2861), kept
262
+ * across rounds. A round given none keeps its own, which it forgets when
263
+ * it returns.
264
+ */
265
+ besideState?: BesideState;
266
+ /** How a run beside the turns is started. Default: `chant run <op>` in a process of its own ({@link spawnBesideRun}). */
267
+ launchBeside?: BesideLauncher;
268
+ /** The environment the steward operator was started for (`--env`), passed on to a run it starts beside its turns. */
269
+ stewardEnv?: string;
188
270
  }
189
271
 
190
272
  /** A steward's Op whose last run waits on a decision point (#2749). */
@@ -336,12 +418,12 @@ async function releaseStewardTurn(steward: string, holder: string, token: string
336
418
  }
337
419
 
338
420
  /**
339
- * The Ops one round considers: a steward's Ops, or every ConvergeOp. A
340
- * steward's Op with no schedule is considered only to resume a run of the
341
- * steward's that waits on a decision point.
421
+ * The Ops one round runs as turns: a steward's Ops but those beside its turns
422
+ * (#2861), or every ConvergeOp. A steward's Op with no schedule is considered
423
+ * only to resume a run of the steward's that waits on a decision point.
342
424
  */
343
425
  async function roundOps(opts: OperatorRoundOptions): Promise<DiscoveredOp["config"][]> {
344
- if (opts.steward) return [...opts.steward.ops];
426
+ if (opts.steward) return stewardTurnOps(opts.steward);
345
427
  const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
346
428
  return ops.map((d) => d.config);
347
429
  }
@@ -388,6 +470,10 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
388
470
 
389
471
  if (!(await holdSteward())) return events;
390
472
 
473
+ // The runs started beside the turns that ended since the last round (#2861).
474
+ const besideState = opts.besideState ?? createBesideState();
475
+ events.push(...besideState.ended.splice(0));
476
+
391
477
  // A steward's Ops whose last run waits on a decision point (#2749), read
392
478
  // once per round, and the state of those questions now.
393
479
  const waiting = steward ? await stewardWaits(steward, opts) : new Map<string, WaitState>();
@@ -395,6 +481,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
395
481
  // been approved since.
396
482
  const gated = steward ? await stewardGates(steward, opts) : new Map<string, GateWaitState>();
397
483
 
484
+ let stewardLost = false;
398
485
  for (const config of await roundOps(opts)) {
399
486
  const env = steward ? runEnvOf(config) : (envOf(config) ?? "unknown");
400
487
  const wait = waiting.get(config.name);
@@ -548,12 +635,152 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
548
635
  restoreTurn?.();
549
636
  if (steward && turn?.acquired) await releaseStewardTurn(steward.name, holder, turn.lease!.token, { cwd: opts.cwd });
550
637
  }
551
- if (!(await holdSteward())) break;
638
+ if (!(await holdSteward())) {
639
+ stewardLost = true;
640
+ break;
641
+ }
642
+ }
643
+
644
+ // The Ops beside the turns (#2861) are considered whatever became of the
645
+ // turns: a turn in progress holds none of them. Only a steward that lost its
646
+ // own lease starts nothing.
647
+ if (steward && !stewardLost) {
648
+ await besideRound(steward, opts, holder, besideState, waiting, gated, events);
552
649
  }
553
650
 
554
651
  return events;
555
652
  }
556
653
 
654
+ /**
655
+ * One round for a steward's Ops beside its turns (#2861). For each one not
656
+ * already running: start it when a run of the steward's waits on a question
657
+ * now answered or a gate now approved, when its cron fires, or when its ready
658
+ * step names work it has not started a run for. The run is started and not
659
+ * waited for.
660
+ */
661
+ async function besideRound(
662
+ steward: StewardDeclaration,
663
+ opts: OperatorRoundOptions,
664
+ holder: string,
665
+ state: BesideState,
666
+ waiting: Map<string, WaitState>,
667
+ gated: Map<string, GateWaitState>,
668
+ events: OperatorTickEvent[],
669
+ ): Promise<void> {
670
+ for (const beside of stewardBesideOf(steward)) {
671
+ const config = steward.ops.find((op) => op.name === beside.op);
672
+ if (!config) continue;
673
+ const env = runEnvOf(config);
674
+
675
+ // The cron is read whether or not a run is in flight, so a fire during a
676
+ // run is dropped (overlap "skip") rather than owed afterwards.
677
+ const cron = config.schedule?.cron;
678
+ let due = false;
679
+ if (cron) {
680
+ const now = (opts.now ?? (() => new Date()))();
681
+ const lastSeen = opts.scheduleState?.get(config.name);
682
+ due = lastSeen === undefined ? cronMatches(cron, now) : cronDueBetween(cron, lastSeen, now);
683
+ opts.scheduleState?.set(config.name, now);
684
+ }
685
+
686
+ const mine = state.running.get(config.name);
687
+ if (mine) {
688
+ events.push({ kind: "beside-running", op: config.name, env, heldBy: mine.holder });
689
+ continue;
690
+ }
691
+ // A run another process holds the lease for: a hand `chant run`, or one
692
+ // this steward started before its operator restarted.
693
+ let current: LeaseRecord | undefined;
694
+ try {
695
+ current = (await readLease(config.name, { cwd: opts.cwd })).record;
696
+ } catch (err) {
697
+ events.push({ kind: "lease-error", op: config.name, env, error: err instanceof Error ? err.message : String(err) });
698
+ continue;
699
+ }
700
+ const now = (opts.now ?? (() => new Date()))();
701
+ if (current && new Date(current.expiresAt).getTime() > now.getTime()) {
702
+ events.push({ kind: "beside-running", op: config.name, env, heldBy: current.holder });
703
+ continue;
704
+ }
705
+
706
+ const wait = waiting.get(config.name);
707
+ const stopped = gated.get(config.name);
708
+ const standing: OperatorTickEvent | undefined = wait
709
+ ? { kind: "waiting-on-point", op: config.name, env, point: wait.point, question: wait.question, state: wait.state }
710
+ : stopped
711
+ ? { kind: "waiting-on-gate", op: config.name, env, gateOp: stopped.gateOp, gate: stopped.gate }
712
+ : undefined;
713
+
714
+ let why: BesideWhy | undefined;
715
+ if (wait?.answered) why = "resumed";
716
+ else if (stopped?.approved) why = "approved";
717
+ else if (due) why = "cron";
718
+
719
+ let keys: string[] | undefined;
720
+ if (!why && beside.ready) {
721
+ const answer = await askReady(beside.ready, opts.activities, opts.signal);
722
+ if ("error" in answer) {
723
+ events.push({ kind: "ready-failed", op: config.name, env, error: answer.error });
724
+ continue;
725
+ }
726
+ if (answer.ready) {
727
+ const seen = state.started.get(config.name) ?? new Set<string>();
728
+ if (answer.keys.length === 0 || answer.keys.some((k) => !seen.has(k))) {
729
+ why = "ready";
730
+ keys = answer.keys;
731
+ } else {
732
+ events.push(standing ?? { kind: "skipped-not-ready", op: config.name, env, already: answer.keys });
733
+ continue;
734
+ }
735
+ } else {
736
+ events.push(standing ?? { kind: "skipped-not-ready", op: config.name, env });
737
+ continue;
738
+ }
739
+ }
740
+ if (!why) {
741
+ const idle: OperatorTickEvent | undefined = standing ?? (cron ? { kind: "skipped-not-due", op: config.name, env, cron } : undefined);
742
+ if (idle) events.push(idle);
743
+ continue;
744
+ }
745
+
746
+ const runHolder = stewardWorkHolder(steward.name, config.name, holder);
747
+ let handle: BesideHandle;
748
+ try {
749
+ handle = (opts.launchBeside ?? spawnBesideRun)({
750
+ op: config,
751
+ steward: steward.name,
752
+ ...(opts.stewardEnv !== undefined ? { env: opts.stewardEnv } : {}),
753
+ holder: runHolder,
754
+ cwd: opts.cwd ?? process.cwd(),
755
+ ...(opts.leaseTtlMs !== undefined ? { leaseTtlMs: opts.leaseTtlMs } : {}),
756
+ });
757
+ } catch (err) {
758
+ events.push({ kind: "tick-failed", op: config.name, env, error: err instanceof Error ? err.message : String(err) });
759
+ continue;
760
+ }
761
+ if (keys) {
762
+ const seen = state.started.get(config.name) ?? new Set<string>();
763
+ for (const k of keys) seen.add(k);
764
+ state.started.set(config.name, seen);
765
+ }
766
+ state.running.set(config.name, { handle, holder: runHolder, why });
767
+ void handle.done.then((exit) => {
768
+ state.running.delete(config.name);
769
+ state.ended.push({ kind: "beside-ended", op: config.name, env, code: exit.code, ...(exit.error ? { error: exit.error } : {}) });
770
+ });
771
+ events.push({
772
+ kind: "beside-started",
773
+ op: config.name,
774
+ env,
775
+ why,
776
+ holder: runHolder,
777
+ ...(keys && keys.length > 0 ? { keys } : {}),
778
+ ...(why === "resumed" && wait ? { resumed: wait.question } : {}),
779
+ ...(why === "approved" && stopped ? { approved: { op: stopped.gateOp, gate: stopped.gate } } : {}),
780
+ });
781
+ }
782
+ }
783
+
557
784
  /**
558
785
  * What a failed tick says about itself (#2301).
559
786
  *
@@ -673,17 +900,24 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
673
900
  // lands on a firing minute — a fresh operator owes no catch-up for the ticks
674
901
  // it was not running for.
675
902
  const scheduleState = opts.scheduleState ?? new Map<string, Date>();
903
+ // The runs a steward starts beside its turns (#2861), for the life of the
904
+ // loop. When the loop stops, so do they.
905
+ const besideState = opts.besideState ?? createBesideState();
676
906
  const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
677
907
  const subscribers = opts.subscribers ?? [];
678
908
 
679
- // Nothing to subscribe to: the loop below is byte for byte the loop that
680
- // shipped before #1981, and takes none of the machinery's cost.
909
+ // Nothing to subscribe to: the loop below is the loop that shipped before
910
+ // #1981, and takes none of the machinery's cost.
681
911
  if (subscribers.length === 0) {
682
- while (!opts.signal?.aborted) {
683
- const events = await runOperatorRound({ ...opts, holder, scheduleState });
684
- opts.onRound?.(events);
685
- if (opts.signal?.aborted) break;
686
- await sleepAbortable(intervalMs, opts.signal);
912
+ try {
913
+ while (!opts.signal?.aborted) {
914
+ const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
915
+ opts.onRound?.(events);
916
+ if (opts.signal?.aborted) break;
917
+ await sleepAbortable(intervalMs, opts.signal);
918
+ }
919
+ } finally {
920
+ await stopBesideRuns(besideState);
687
921
  }
688
922
  return;
689
923
  }
@@ -736,7 +970,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
736
970
  while (!opts.signal?.aborted) {
737
971
  gate.roundStarted();
738
972
  const startedAt = Date.now();
739
- const events = await runOperatorRound({ ...opts, holder, scheduleState });
973
+ const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
740
974
  opts.onRound?.(events);
741
975
  if (opts.signal?.aborted) break;
742
976
 
@@ -750,6 +984,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
750
984
  }
751
985
  } finally {
752
986
  await closeAll();
987
+ await stopBesideRuns(besideState);
753
988
  }
754
989
  }
755
990
 
@@ -780,5 +1015,18 @@ export function formatRoundLine(event: OperatorTickEvent): string {
780
1015
  return `operator: steward ${event.steward} skipped=1(steward-lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
781
1016
  case "turn-busy":
782
1017
  return `operator: ${event.op}@${event.env} skipped=1(turn-held${event.heldBy ? `:${event.heldBy}` : ""}, steward "${event.steward}" is already mid-turn)`;
1018
+ case "beside-started":
1019
+ return `operator: ${event.op}@${event.env} started=1(beside:${event.why}) holder="${event.holder}"` +
1020
+ (event.keys ? ` keys="${event.keys.join(",")}"` : "") +
1021
+ (event.resumed ? ` resumed="${event.resumed}"` : "") +
1022
+ (event.approved ? ` approved="${event.approved.op}/${event.approved.gate}"` : "");
1023
+ case "beside-running":
1024
+ return `operator: ${event.op}@${event.env} skipped=1(beside-running${event.heldBy ? `:${event.heldBy}` : ""})`;
1025
+ case "beside-ended":
1026
+ return `operator: ${event.op}@${event.env} ended=1(beside) exit=${event.code ?? "none"}` + (event.error ? ` error="${event.error}"` : "");
1027
+ case "skipped-not-ready":
1028
+ return `operator: ${event.op}@${event.env} skipped=1(not-ready${event.already ? `:already-started:${event.already.join(",")}` : ""})`;
1029
+ case "ready-failed":
1030
+ return `operator: ${event.op}@${event.env} failed=1(ready) error="${event.error}"`;
783
1031
  }
784
1032
  }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * An Op a steward runs beside its turns (#2861): the declaration, a long run
3
+ * that holds none of the steward's turns up, a hand run that takes the Op's
4
+ * lease and not the turn, and the operator stopping the runs it started.
5
+ */
6
+ import { describe, test, expect, vi } from "vitest";
7
+ import { withTestDir } from "@intentius/chant-test-utils";
8
+ import { spawnSync } from "node:child_process";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
12
+ import type { ActivityStep, OpConfig } from "./types";
13
+ import { declareSteward, readinessKeys, stewardBesideFor, stewardTurnOps, stewardTurnLeaseName } from "./steward";
14
+ import { createBesideState, formatRoundLine, runOperatorForever, runOperatorRound, waitForBesideRuns, type OperatorTickEvent } from "./operator";
15
+ import { askReady, holdBesideLease, inProcessBesideLauncher, type BesideLauncher } from "./steward-beside";
16
+ import { stepOutput } from "./step-output-ref";
17
+ import { readLease } from "../lifecycle/lease";
18
+ import { readRunLedger } from "../lifecycle/run-ledger";
19
+
20
+ const PROFILES: Record<string, ActivityProfile> = {};
21
+
22
+ function op(name: string, cron?: string, fn = "tick"): OpConfig {
23
+ return {
24
+ name,
25
+ overview: `${name} fixture`,
26
+ phases: [{ name: "Run", steps: [{ kind: "activity", fn, args: {} }] }],
27
+ ...(cron ? { schedule: { cron, overlap: "skip" as const } } : {}),
28
+ };
29
+ }
30
+
31
+ const ready = (fn = "ready"): ActivityStep => ({ kind: "activity", fn, args: {} });
32
+
33
+ function git(args: string[], cwd: string): void {
34
+ spawnSync("git", args, { cwd, encoding: "utf-8" });
35
+ }
36
+
37
+ function initRepo(dir: string): void {
38
+ git(["init", "-q", "-b", "main"], dir);
39
+ git(["config", "user.email", "test@chant.dev"], dir);
40
+ git(["config", "user.name", "Test"], dir);
41
+ mkdirSync(join(dir, "app"), { recursive: true });
42
+ writeFileSync(join(dir, "app", "index.ts"), "export const hello = 1;\n");
43
+ git(["add", "app/index.ts"], dir);
44
+ git(["commit", "-q", "-m", "init"], dir);
45
+ }
46
+
47
+ const kinds = (events: OperatorTickEvent[]) => events.map((e) => [e.kind, e.op]);
48
+
49
+ describe("declareSteward's beside (#2861)", () => {
50
+ test("lists the Ops beside the turns after its turns' Ops, and names them", () => {
51
+ const steward = declareSteward({
52
+ name: "box-steward",
53
+ ops: [op("converge", "* * * * *")],
54
+ beside: [{ op: op("dispatch", undefined, "build"), ready: ready() }, op("rebuild", "0 3 * * *")],
55
+ });
56
+ expect(steward.ops.map((o) => o.name)).toEqual(["converge", "dispatch", "rebuild"]);
57
+ expect(steward.beside).toEqual([{ op: "dispatch", ready: ready() }, { op: "rebuild", ready: null }]);
58
+ expect(stewardTurnOps(steward).map((o) => o.name)).toEqual(["converge"]);
59
+ expect(stewardBesideFor(steward, "dispatch")?.ready).toEqual(ready());
60
+ expect(stewardBesideFor(steward, "converge")).toBeUndefined();
61
+ // A declaration from before #2861 has no beside, and every Op is a turn.
62
+ const { beside: _dropped, ...older } = steward;
63
+ expect(stewardTurnOps(older as typeof steward).map((o) => o.name)).toEqual(["converge", "dispatch", "rebuild"]);
64
+ });
65
+
66
+ test("refuses an Op both in ops and beside, a ready that is not one activity step, and one that reads a step's output", () => {
67
+ const converge = op("converge", "* * * * *");
68
+ expect(() => declareSteward({ name: "s", ops: [converge], beside: [converge] })).toThrow(/listed twice/);
69
+ expect(() =>
70
+ declareSteward({ name: "s", ops: [], beside: [{ op: op("dispatch"), ready: { kind: "gate", gate: "g" } as unknown as ActivityStep }] }),
71
+ ).toThrow(/ready is one activity step/);
72
+ expect(() =>
73
+ declareSteward({ name: "s", ops: [], beside: [{ op: op("dispatch"), ready: { kind: "activity", fn: "shellCmd", args: { cmd: stepOutput("pick", "json") } } }] }),
74
+ ).toThrow(/reads another step's output/);
75
+ // Started on its own, it can't be handed the work item a run names.
76
+ const leased: OpConfig = { ...op("dispatch"), changesCheckout: true, workLease: {} };
77
+ expect(() => declareSteward({ name: "s", ops: [], beside: [{ op: leased, ready: ready() }] })).toThrow(/workLease names no item/);
78
+ });
79
+
80
+ test("a ready step's answer: a list of keys, a string, true, or no work", () => {
81
+ expect(readinessKeys({ stdout: "", json: ["W-1:0", "intent:ws-1"] })).toEqual({ ready: true, keys: ["W-1:0", "intent:ws-1"] });
82
+ expect(readinessKeys([{ item: "W-2" }])).toEqual({ ready: true, keys: ['{"item":"W-2"}'] });
83
+ expect(readinessKeys("W-3")).toEqual({ ready: true, keys: ["W-3"] });
84
+ expect(readinessKeys(true)).toEqual({ ready: true, keys: [] });
85
+ for (const none of [false, null, "", [], { json: null }]) expect(readinessKeys(none)).toEqual({ ready: false, keys: [] });
86
+ expect(readinessKeys({ ready: [] })).toBeNull();
87
+ });
88
+
89
+ test("askReady reports a step that fails, answers in another shape, or isn't loaded", async () => {
90
+ const acts = new Map<string, ActivityFn>([
91
+ ["boom", async () => { throw new Error("no factory here"); }],
92
+ ["odd", async () => ({ ready: [{ item: "W-1" }], held: null })],
93
+ ]);
94
+ expect(await askReady(ready("boom"), acts)).toEqual({ error: "no factory here" });
95
+ expect(await askReady(ready("odd"), acts)).toMatchObject({ error: expect.stringContaining("neither true") });
96
+ expect(await askReady(ready("missing"), acts)).toMatchObject({ error: expect.stringContaining('"missing" is not loaded') });
97
+ });
98
+ });
99
+
100
+ describe("an Op beside a steward's turns (#2861)", () => {
101
+ test("a long run beside a per-minute converge: converge ticks every minute while it runs, and no work is started twice", async () => {
102
+ await withTestDir(async (dir) => {
103
+ initRepo(dir);
104
+ let finish: () => void = () => {};
105
+ const building = new Promise<void>((resolve) => { finish = resolve; });
106
+ const log: string[] = [];
107
+ const activities = new Map<string, ActivityFn>([
108
+ ["tick", async () => { log.push("converge"); return { ok: true }; }],
109
+ ["build", async () => { log.push("build:start"); await building; log.push("build:end"); return { built: true }; }],
110
+ ["ready", async () => ({ stdout: "", json: ["W-1:0"] })],
111
+ ]);
112
+ const steward = declareSteward({
113
+ name: "box-steward",
114
+ ops: [op("converge", "* * * * *")],
115
+ beside: [{ op: op("dispatch", undefined, "build"), ready: ready() }],
116
+ });
117
+ const besideState = createBesideState();
118
+ const scheduleState = new Map<string, Date>();
119
+ const launchBeside = inProcessBesideLauncher(activities, PROFILES);
120
+ // Cron minutes are local time, so the rounds are too. The leases use the
121
+ // wall clock, so a fixed `now` is only handed to the cron.
122
+ let minute = 1;
123
+ const round = () => {
124
+ const at = new Date(2026, 8, 25, 10, minute++, 0);
125
+ return runOperatorRound({ cwd: dir, holder: "op1", steward, activities, profiles: PROFILES, besideState, scheduleState, launchBeside, now: () => at });
126
+ };
127
+
128
+ // 10:01: converge ticks as a turn; the ready step names work, and dispatch starts beside it.
129
+ const first = await round();
130
+ expect(kinds(first)).toEqual([["ticked", "converge"], ["beside-started", "dispatch"]]);
131
+ expect(first[1]).toMatchObject({ why: "ready", keys: ["W-1:0"], holder: "box-steward/dispatch@op1" });
132
+ expect(formatRoundLine(first[1])).toBe('operator: dispatch@local started=1(beside:ready) holder="box-steward/dispatch@op1" keys="W-1:0"');
133
+ await vi.waitFor(() => expect(log).toContain("build:start"));
134
+
135
+ // Mid-build the run holds the Op's own lease, and not the steward's turn.
136
+ expect((await readLease("dispatch", { cwd: dir })).record?.holder).toBe("box-steward/dispatch@op1");
137
+ expect((await readLease(stewardTurnLeaseName("box-steward"), { cwd: dir })).record).toBeUndefined();
138
+
139
+ // 10:02 and 10:03: converge ticks each minute while the build runs, and no second run starts.
140
+ for (let i = 0; i < 2; i++) {
141
+ const mid = await round();
142
+ expect(kinds(mid)).toEqual([["ticked", "converge"], ["beside-running", "dispatch"]]);
143
+ }
144
+ expect(log.filter((l) => l === "converge")).toHaveLength(3);
145
+ expect(log).not.toContain("build:end");
146
+
147
+ finish();
148
+ await waitForBesideRuns(besideState);
149
+ expect((await readLease("dispatch", { cwd: dir })).record).toBeUndefined();
150
+ const run = (await readRunLedger("local", "dispatch", { cwd: dir })).records.at(-1)!;
151
+ expect(run).toMatchObject({ status: "ok", steward: "box-steward" });
152
+
153
+ // 10:04: the end is reported, and the same work still named is not started again.
154
+ const after = await round();
155
+ expect(after).toEqual([
156
+ { kind: "beside-ended", op: "dispatch", env: "local", code: 0 },
157
+ expect.objectContaining({ kind: "ticked", op: "converge" }),
158
+ { kind: "skipped-not-ready", op: "dispatch", env: "local", already: ["W-1:0"] },
159
+ ]);
160
+ expect(log.filter((l) => l === "build:start")).toHaveLength(1);
161
+ });
162
+ });
163
+
164
+ test("a hand run holds the Op's lease and not the turn: converge still ticks, and the operator starts no second run", async () => {
165
+ await withTestDir(async (dir) => {
166
+ initRepo(dir);
167
+ const log: string[] = [];
168
+ const activities = new Map<string, ActivityFn>([
169
+ ["tick", async () => { log.push("converge"); return { ok: true }; }],
170
+ ["ready", async () => true],
171
+ ]);
172
+ const steward = declareSteward({
173
+ name: "box-steward",
174
+ ops: [op("converge", "* * * * *")],
175
+ beside: [{ op: op("dispatch", undefined, "build"), ready: ready() }],
176
+ });
177
+ const launched: string[] = [];
178
+ const launchBeside: BesideLauncher = (start) => {
179
+ launched.push(start.op.name);
180
+ return { done: Promise.resolve({ code: 0 }), stop: () => {} };
181
+ };
182
+
183
+ // What `chant run dispatch` takes for an Op beside the turns.
184
+ const hand = await holdBesideLease("dispatch", "hand-run", { cwd: dir });
185
+ expect(hand.acquired).toBe(true);
186
+ expect((await readLease(stewardTurnLeaseName("box-steward"), { cwd: dir })).record).toBeUndefined();
187
+
188
+ const at = new Date(2026, 8, 25, 10, 1, 0);
189
+ const events = await runOperatorRound({ cwd: dir, holder: "op1", steward, activities, profiles: PROFILES, launchBeside, now: () => at });
190
+ expect(events.map((e) => e.kind)).toEqual(["ticked", "beside-running"]);
191
+ expect(events[1]).toEqual({ kind: "beside-running", op: "dispatch", env: "local", heldBy: "hand-run" });
192
+ expect(log).toEqual(["converge"]);
193
+ expect(launched).toEqual([]);
194
+
195
+ // A second hand run is refused while the first holds the lease.
196
+ expect(await holdBesideLease("dispatch", "hand-run-2", { cwd: dir })).toEqual({ acquired: false, heldBy: "hand-run" });
197
+ if (hand.acquired) await hand.release();
198
+ expect((await readLease("dispatch", { cwd: dir })).record).toBeUndefined();
199
+ const next = await runOperatorRound({ cwd: dir, holder: "op1", steward, activities, profiles: PROFILES, launchBeside, now: () => at });
200
+ expect(next.map((e) => e.kind)).toContain("beside-started");
201
+ expect(launched).toEqual(["dispatch"]);
202
+ });
203
+ });
204
+
205
+ test("the lease a long run holds is renewed while it runs", async () => {
206
+ await withTestDir(async (dir) => {
207
+ initRepo(dir);
208
+ const held = await holdBesideLease("dispatch", "h", { cwd: dir, ttlMs: 900 });
209
+ expect(held.acquired).toBe(true);
210
+ if (!held.acquired) return;
211
+ const first = held.lease.expiresAt;
212
+ await vi.waitFor(async () => {
213
+ const now = (await readLease("dispatch", { cwd: dir })).record;
214
+ expect(now?.token).toBe(held.lease.token);
215
+ expect(now!.expiresAt > first).toBe(true);
216
+ }, { timeout: 3000 });
217
+ await held.release();
218
+ });
219
+ });
220
+
221
+ test("a cron fire starts it, and one that fires while a run is in flight is dropped", async () => {
222
+ await withTestDir(async (dir) => {
223
+ initRepo(dir);
224
+ const steward = declareSteward({ name: "box-steward", ops: [], beside: [op("nightly", "0 3 * * *")] });
225
+ let end: () => void = () => {};
226
+ const launchBeside: BesideLauncher = () => ({ done: new Promise((resolve) => { end = () => resolve({ code: 0 }); }), stop: () => {} });
227
+ const besideState = createBesideState();
228
+ const scheduleState = new Map<string, Date>();
229
+ const round = (h: number, m: number) =>
230
+ runOperatorRound({ cwd: dir, holder: "op1", steward, activities: new Map(), profiles: PROFILES, besideState, scheduleState, launchBeside, now: () => new Date(2026, 8, 25, h, m, 0) });
231
+ expect(kinds(await round(2, 59))).toEqual([["skipped-not-due", "nightly"]]);
232
+ expect(await round(3, 0)).toEqual([expect.objectContaining({ kind: "beside-started", op: "nightly", why: "cron" })]);
233
+ expect(kinds(await round(3, 1))).toEqual([["beside-running", "nightly"]]);
234
+ end();
235
+ await waitForBesideRuns(besideState);
236
+ expect(kinds(await round(3, 2))).toEqual([["beside-ended", "nightly"], ["skipped-not-due", "nightly"]]);
237
+ });
238
+ });
239
+
240
+ test("the operator stops the runs it started when it stops", async () => {
241
+ await withTestDir(async (dir) => {
242
+ initRepo(dir);
243
+ const steward = declareSteward({ name: "box-steward", ops: [], beside: [{ op: op("dispatch"), ready: ready() }] });
244
+ const controller = new AbortController();
245
+ const stopped: string[] = [];
246
+ const launchBeside: BesideLauncher = (start) => {
247
+ let end: (code: number) => void = () => {};
248
+ const done = new Promise<{ code: number }>((resolve) => { end = (code) => resolve({ code }); });
249
+ return { done, stop: () => { stopped.push(start.op.name); end(1); } };
250
+ };
251
+ await runOperatorForever({
252
+ cwd: dir,
253
+ holder: "op1",
254
+ steward,
255
+ activities: new Map<string, ActivityFn>([["ready", async () => true]]),
256
+ profiles: PROFILES,
257
+ launchBeside,
258
+ intervalMs: 10,
259
+ signal: controller.signal,
260
+ onRound: (events) => {
261
+ if (events.some((e) => e.kind === "beside-started")) controller.abort();
262
+ },
263
+ });
264
+ expect(stopped).toEqual(["dispatch"]);
265
+ });
266
+ });
267
+ });