@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.
Files changed (114) 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/mcp/server.d.ts +10 -4
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/op/builders.d.ts +14 -3
  9. package/dist/op/builders.d.ts.map +1 -1
  10. package/dist/op/index.d.ts +6 -3
  11. package/dist/op/index.d.ts.map +1 -1
  12. package/dist/op/operator.d.ts +90 -0
  13. package/dist/op/operator.d.ts.map +1 -1
  14. package/dist/op/steward-beside.d.ts +84 -0
  15. package/dist/op/steward-beside.d.ts.map +1 -0
  16. package/dist/op/steward.d.ts +87 -2
  17. package/dist/op/steward.d.ts.map +1 -1
  18. package/dist/workspace/box-services.d.ts +31 -0
  19. package/dist/workspace/box-services.d.ts.map +1 -0
  20. package/dist/workspace/compose-graph.d.ts +11 -0
  21. package/dist/workspace/compose-graph.d.ts.map +1 -1
  22. package/dist/workspace/composites.d.ts +5 -1
  23. package/dist/workspace/composites.d.ts.map +1 -1
  24. package/dist/workspace/declaration.d.ts +37 -0
  25. package/dist/workspace/declaration.d.ts.map +1 -1
  26. package/dist/workspace/declaration.schema.json +57 -1
  27. package/dist/workspace/graph-cache.d.ts +168 -0
  28. package/dist/workspace/graph-cache.d.ts.map +1 -0
  29. package/dist/workspace/graph-cli.d.ts +11 -5
  30. package/dist/workspace/graph-cli.d.ts.map +1 -1
  31. package/dist/workspace/kind-readers.d.ts +39 -0
  32. package/dist/workspace/kind-readers.d.ts.map +1 -0
  33. package/dist/workspace/kinds.d.ts +29 -0
  34. package/dist/workspace/kinds.d.ts.map +1 -1
  35. package/dist/workspace/member-commands.d.ts +15 -1
  36. package/dist/workspace/member-commands.d.ts.map +1 -1
  37. package/dist/workspace/member-run.d.ts +2 -0
  38. package/dist/workspace/member-run.d.ts.map +1 -1
  39. package/dist/workspace/reason-codes.d.ts +3 -1
  40. package/dist/workspace/reason-codes.d.ts.map +1 -1
  41. package/dist/workspace/records-cli.d.ts +10 -1
  42. package/dist/workspace/records-cli.d.ts.map +1 -1
  43. package/dist/workspace/records-write.d.ts +4 -2
  44. package/dist/workspace/records-write.d.ts.map +1 -1
  45. package/dist/workspace/records.d.ts +8 -3
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/status-stewards.d.ts +23 -7
  48. package/dist/workspace/status-stewards.d.ts.map +1 -1
  49. package/dist/workspace/status.d.ts +12 -0
  50. package/dist/workspace/status.d.ts.map +1 -1
  51. package/dist/workspace/work-evidence.d.ts +1 -1
  52. package/dist/workspace/work-evidence.d.ts.map +1 -1
  53. package/dist/workspace/workspace-kinds.schema.json +26 -0
  54. package/package.json +1 -1
  55. package/src/cli/commands/carve-bridge.test.ts +7 -3
  56. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  57. package/src/cli/handlers/operator.ts +53 -11
  58. package/src/cli/handlers/run.test.ts +71 -0
  59. package/src/cli/handlers/run.ts +65 -7
  60. package/src/cli/main.ts +17 -6
  61. package/src/cli/mcp/server.test.ts +14 -0
  62. package/src/cli/mcp/server.ts +10 -4
  63. package/src/cli/mcp/workspace-tools.ts +1 -1
  64. package/src/cli/registry.ts +2 -0
  65. package/src/cli/static-config-read.test.ts +8 -2
  66. package/src/meta/source-is-text.test.ts +21 -3
  67. package/src/okf.test.ts +6 -1
  68. package/src/op/builders.ts +14 -3
  69. package/src/op/index.ts +8 -2
  70. package/src/op/operator.ts +264 -16
  71. package/src/op/steward-beside.test.ts +267 -0
  72. package/src/op/steward-beside.ts +219 -0
  73. package/src/op/steward-points.test.ts +61 -1
  74. package/src/op/steward.ts +135 -3
  75. package/src/workspace/box-services.test.ts +129 -0
  76. package/src/workspace/box-services.ts +51 -0
  77. package/src/workspace/checks/boxes.test.ts +1 -0
  78. package/src/workspace/compose-graph.test.ts +1 -0
  79. package/src/workspace/compose-graph.ts +11 -0
  80. package/src/workspace/composites.test.ts +1 -1
  81. package/src/workspace/composites.ts +12 -5
  82. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  83. package/src/workspace/declaration.schema.json +57 -1
  84. package/src/workspace/declaration.ts +104 -0
  85. package/src/workspace/graph-cache.test.ts +343 -0
  86. package/src/workspace/graph-cache.ts +409 -0
  87. package/src/workspace/graph-cli.ts +93 -26
  88. package/src/workspace/graph-contract.test.ts +129 -6
  89. package/src/workspace/graph.schema.json +21 -1
  90. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  91. package/src/workspace/kind-readers.test.ts +134 -0
  92. package/src/workspace/kind-readers.ts +111 -0
  93. package/src/workspace/kinds.test.ts +49 -0
  94. package/src/workspace/kinds.ts +59 -2
  95. package/src/workspace/member-commands.test.ts +15 -0
  96. package/src/workspace/member-commands.ts +47 -7
  97. package/src/workspace/member-run.ts +10 -2
  98. package/src/workspace/reason-codes.ts +3 -1
  99. package/src/workspace/records-amend.schema.json +1 -0
  100. package/src/workspace/records-cli.ts +20 -12
  101. package/src/workspace/records-contract.test.ts +2 -1
  102. package/src/workspace/records-new.schema.json +1 -0
  103. package/src/workspace/records-quorum.test.ts +10 -3
  104. package/src/workspace/records-sessions-write.test.ts +2 -1
  105. package/src/workspace/records-write.test.ts +101 -1
  106. package/src/workspace/records-write.ts +50 -6
  107. package/src/workspace/records.ts +22 -5
  108. package/src/workspace/status-contract.test.ts +31 -1
  109. package/src/workspace/status-stewards.ts +39 -6
  110. package/src/workspace/status.schema.json +49 -4
  111. package/src/workspace/status.ts +22 -0
  112. package/src/workspace/trust/record-seal.test.ts +26 -2
  113. package/src/workspace/work-evidence.schema.json +1 -0
  114. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -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 {
@@ -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
  }