@intx/workflow-host 0.3.0 → 0.4.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 (78) hide show
  1. package/README.md +21 -4
  2. package/dist/adapters/mail-part-store.d.ts +46 -0
  3. package/dist/adapters/mail-part-store.js +251 -0
  4. package/dist/adapters/repo-store.js +5 -14
  5. package/dist/adapters/spawn-child.d.ts +42 -6
  6. package/dist/adapters/spawn-child.js +8 -18
  7. package/dist/adapters/step-invoker.d.ts +52 -2
  8. package/dist/adapters/step-invoker.js +230 -60
  9. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  10. package/dist/adapters/substrate-mailbox-store.js +404 -0
  11. package/dist/child/child-mailbox-reader.d.ts +10 -0
  12. package/dist/child/child-mailbox-reader.js +23 -0
  13. package/dist/child/credential-cell.d.ts +8 -0
  14. package/dist/child/credential-cell.js +66 -0
  15. package/dist/child/from-process-env.d.ts +12 -0
  16. package/dist/child/from-process-env.js +6 -0
  17. package/dist/child/index.d.ts +4 -1
  18. package/dist/child/index.js +4 -1
  19. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  20. package/dist/child/mailbox-mutation-bridge.js +101 -0
  21. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  22. package/dist/child/mailbox-watch-registry.js +61 -0
  23. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  24. package/dist/child/outbound-mail-bridge.js +20 -32
  25. package/dist/child/pending-request.d.ts +89 -0
  26. package/dist/child/pending-request.js +80 -0
  27. package/dist/child/run-child.d.ts +69 -7
  28. package/dist/child/run-child.js +307 -75
  29. package/dist/child/substrate-write-bridge.d.ts +3 -2
  30. package/dist/child/substrate-write-bridge.js +21 -38
  31. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  32. package/dist/child/supervisor-backed-transport.js +205 -62
  33. package/dist/child/warm-agent-cache.d.ts +44 -4
  34. package/dist/child/warm-agent-cache.js +41 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +4 -3
  37. package/dist/ipc/control-channel.d.ts +93 -2
  38. package/dist/ipc/control-channel.js +147 -47
  39. package/dist/ipc/index.d.ts +1 -1
  40. package/dist/ipc/index.js +1 -1
  41. package/dist/run-body-then-cleanup.d.ts +17 -0
  42. package/dist/run-body-then-cleanup.js +38 -0
  43. package/dist/seams/scheduler.d.ts +12 -0
  44. package/dist/seams/scheduler.js +13 -4
  45. package/dist/supervisor/cancel-signing.js +3 -7
  46. package/dist/supervisor/credentials.d.ts +17 -5
  47. package/dist/supervisor/recycle.d.ts +5 -1
  48. package/dist/supervisor/run-event-compaction.d.ts +2 -2
  49. package/dist/supervisor/run-event-compaction.js +11 -16
  50. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  51. package/dist/supervisor/run-event-recovery.js +45 -0
  52. package/dist/supervisor/supervisor.d.ts +27 -4
  53. package/dist/supervisor/supervisor.js +644 -58
  54. package/dist/supervisor/terminal-commit.js +3 -7
  55. package/dist/supervisor/types.d.ts +30 -0
  56. package/dist/testing/change-notifier.d.ts +12 -0
  57. package/dist/testing/change-notifier.js +63 -0
  58. package/dist/testing/index.d.ts +8 -0
  59. package/dist/testing/index.js +16 -0
  60. package/dist/testing/log-capture.d.ts +52 -0
  61. package/dist/testing/log-capture.js +124 -0
  62. package/dist/testing/mail-bus.d.ts +22 -0
  63. package/dist/testing/mail-bus.js +78 -0
  64. package/dist/testing/memory-streams.d.ts +43 -0
  65. package/dist/testing/memory-streams.js +211 -0
  66. package/dist/testing/spawn-observer.d.ts +12 -0
  67. package/dist/testing/spawn-observer.js +36 -0
  68. package/dist/testing/stub-repo-store.d.ts +10 -0
  69. package/dist/testing/stub-repo-store.js +39 -0
  70. package/dist/testing/supervisor-reaper.d.ts +24 -0
  71. package/dist/testing/supervisor-reaper.js +49 -0
  72. package/dist/testing/upstream-frames.d.ts +47 -0
  73. package/dist/testing/upstream-frames.js +94 -0
  74. package/dist/workflow-definition-loader.d.ts +56 -0
  75. package/dist/workflow-definition-loader.js +106 -0
  76. package/package.json +17 -11
  77. package/dist/conversation-text.d.ts +0 -23
  78. package/dist/conversation-text.js +0 -56
@@ -43,7 +43,7 @@ import { type } from "arktype";
43
43
  import { getLogger } from "@intx/log";
44
44
  import { sampleStructuralCounters, forceRepack, } from "./dispatch-attribution.js";
45
45
  import { generateKeyPair } from "@intx/crypto";
46
- import { enqueueInbox as defaultEnqueueInbox, dequeueToProcessing as defaultDequeueToProcessing, markConsumed as defaultMarkConsumed, readOwnedMessageIds, readWorkflowRunLifecycle, replayProcessingToInbox as defaultReplayProcessingToInbox, StaleInboxEnqueueError, DEFAULT_CONSUMED_RETENTION_MS, } from "@intx/hub-sessions/substrate";
46
+ import { enqueueInbox as defaultEnqueueInbox, dequeueToProcessing as defaultDequeueToProcessing, markConsumed as defaultMarkConsumed, scanRunsForBoot, readWorkflowRunLifecycle, replayProcessingToInbox as defaultReplayProcessingToInbox, StaleInboxEnqueueError, DEFAULT_CONSUMED_RETENTION_MS, } from "@intx/hub-sessions/substrate";
47
47
  import { base64Decode, base64Encode, deriveMessageId, deriveWorkflowRunId, signalName, } from "@intx/types";
48
48
  import { RepoId } from "@intx/types/sidecar";
49
49
  import { createControlChannelSender, generateChannelId, generateHmacKey, receiveControlChannel, receiveEventChannel, } from "../ipc/index.js";
@@ -52,12 +52,29 @@ import { commitCancelRequested } from "./cancel-signing.js";
52
52
  import { commitRunFailed } from "./terminal-commit.js";
53
53
  import { buildChildSpawnEnv } from "./spawn-env.js";
54
54
  import { compactRunEvents } from "./run-event-compaction.js";
55
- import { extractConversationText } from "../conversation-text.js";
55
+ import { recoverInterruptedCompactions } from "./run-event-recovery.js";
56
+ import { decodeMail } from "@intx/mime";
57
+ import { commitMail, InvalidMailError } from "../adapters/mail-part-store.js";
58
+ import { mergeCredentialDelivery } from "../child/credential-cell.js";
59
+ import { createSubstrateMailboxStore, MAILBOX_INBOX_DIR, } from "../adapters/substrate-mailbox-store.js";
56
60
  import { createDrainTimeoutAccumulator, DEFAULT_DRAIN_TIMEOUT_MS, } from "./drain-timeout.js";
57
61
  import { createRecyclePolicy, triggerRecycle, } from "./recycle.js";
58
62
  import { createTerminalBroadcaster, } from "./terminal-broadcaster.js";
59
63
  import { DEFAULT_KILL_TIMEOUT_MS, DEFAULT_READY_TIMEOUT_MS, defaultClearTimer, defaultSetTimer, killChildHandle, waitDeadline, } from "./child-termination.js";
60
64
  const logger = getLogger(["workflow-host", "supervisor"]);
65
+ /** IMAP system flag marking a dispatched mailbox entry as read. */
66
+ const MAILBOX_FLAG_SEEN = "\\Seen";
67
+ /**
68
+ * Interchange keyword flag marking a mailbox entry the supervisor has dispatched
69
+ * as a workflow turn (a `trigger.fire` or `signal.deliver`).
70
+ */
71
+ const MAILBOX_FLAG_PROCESSED = "$Processed";
72
+ /**
73
+ * IMAP system flag marking a mailbox entry for expunge. The warm agent sets it
74
+ * (via `mail_flag`) to consume a processed message; a subsequent `expunge`
75
+ * sweeps every entry carrying it out of the live INBOX.
76
+ */
77
+ const MAILBOX_FLAG_DELETED = "\\Deleted";
61
78
  /**
62
79
  * Default crash-loop bound: the supervisor stops respawning and latches
63
80
  * the deployment once the workflow-process child exits unexpectedly this
@@ -117,6 +134,19 @@ export const TERMINAL_OR_PARK_BACKSTOP_MS = 300_000;
117
134
  */
118
135
  export function createWorkflowSupervisor(bindings) {
119
136
  let state = { phase: "idle" };
137
+ // The live credential delivery to seed the child on every spawn and every
138
+ // pre-trigger barrier. Initialized from the deploy-time delivery and MUTATED
139
+ // by `deliverCredentials` on every runtime update, so a mid-life revocation or
140
+ // rotation is durable: the re-assertion sites read THIS mirror, not the frozen
141
+ // `bindings.credentialDelivery`. Reading the frozen delivery would re-add an
142
+ // evicted credential on the next spawn/barrier, and the warm-recycle path
143
+ // (seeded only by the barrier) would lose every runtime update. Normalized on
144
+ // init via the same merge the child applies, so the mirror matches the child's
145
+ // deduped cell from step zero. Lives outside `state` because `state` is a
146
+ // phase union replaced on every transition; this must survive them.
147
+ let currentCredentialDelivery = bindings.credentialDelivery !== undefined
148
+ ? mergeCredentialDelivery(null, bindings.credentialDelivery, undefined)
149
+ : null;
120
150
  /**
121
151
  * ALL runIds the current child cohort is driving, regardless of
122
152
  * who spawned them: supervisor-dispatched + self-discovered.
@@ -486,12 +516,21 @@ export function createWorkflowSupervisor(bindings) {
486
516
  // (spawn handshake, recycle reap, shutdown) owns teardown.
487
517
  function onChildCrash(reason) {
488
518
  if (state.phase === "running") {
489
- logger.error `workflow-process channel crash on live cohort; forcing child down to respawn: {reason}`;
519
+ logger.error `workflow-process channel crash on live cohort; forcing child down to respawn: ${reason}`;
490
520
  state.handle.kill();
491
521
  return;
492
522
  }
493
- logger.error `workflow-process channel crash: {reason}`;
494
- void shutdownInternal({ reason });
523
+ logger.error `workflow-process channel crash: ${reason}`;
524
+ // Only a live, registered supervisor driven down by a channel crash is a
525
+ // self-termination the host must reclaim, and that is `recycling`:
526
+ // `running` took the kill branch above (its exit reaches the crash-loop
527
+ // latch, which carries its own flag), `starting` is the pre-registration
528
+ // initial spawn handshake whose failure the deploy unwind owns, and
529
+ // `stopping` is a teardown already in flight (a host `shutdown()`, or a
530
+ // self-termination already firing). An allowlist, not a denylist, so a
531
+ // future phase defaults to no self-terminate rather than a spurious one.
532
+ const selfTerminated = state.phase === "recycling";
533
+ void shutdownInternal({ reason, selfTerminated });
495
534
  }
496
535
  // Prune crash timestamps older than the sliding window relative to `nowMs`.
497
536
  function pruneCrashTimestamps(nowMs) {
@@ -640,6 +679,7 @@ export function createWorkflowSupervisor(bindings) {
640
679
  await shutdownInternal({
641
680
  reason: `crash-loop: ${reason}`,
642
681
  terminalPhase: "crash-looping",
682
+ selfTerminated: true,
643
683
  });
644
684
  // Commit the RunFailed tombstone AFTER teardown: shutdownInternal has
645
685
  // quiesced the drain accumulators (stop + await disposed), so the
@@ -688,6 +728,10 @@ export function createWorkflowSupervisor(bindings) {
688
728
  if (childGeneration !== armedGeneration ||
689
729
  state.phase !== "running" ||
690
730
  spawnContext === null) {
731
+ // The backoff above announced a respawn before parking. Report the bail
732
+ // too, so a deployment that stops respawning does not leave an operator
733
+ // with a promised respawn and no record of what happened to it.
734
+ logger.info `respawn backoff elapsed but the crashed cohort is no longer the running one; skipping respawn (phase=${state.phase}, armed generation ${String(armedGeneration)}, current generation ${String(childGeneration)}): ${reason}`;
691
735
  return;
692
736
  }
693
737
  const priorRunning = state;
@@ -712,6 +756,153 @@ export function createWorkflowSupervisor(bindings) {
712
756
  // does not latch on a later, unrelated crash.
713
757
  armStableRunResetTimer(childGeneration);
714
758
  }
759
+ // Eager per-run mailbox (§3b inbound). On arrival the supervisor commits each
760
+ // fresh inbound message into the deployment's substrate-backed INBOX and fires
761
+ // a one-way `mailbox.notify` to the child, so the warm agent's `watch` /
762
+ // `mail_wait` observes the arrival mid-turn -- decoupled from the FIFO claim-
763
+ // check dispatch that resolves a run's step input. The supervisor is the sole
764
+ // mailbox writer; the store is its long-lived in-memory mirror over the
765
+ // committed `mailbox/INBOX/` subtree, constructed lazily on the first arrival.
766
+ const mailboxWritePrincipal = {
767
+ kind: "supervisor",
768
+ anchorRunId: bindings.anchorRunId,
769
+ };
770
+ let mailboxStore = null;
771
+ // Claim-check messageId -> assigned mailbox uid, so a message dispatched as a
772
+ // turn can be flagged \Seen/$Processed by uid. In-memory only: a missing entry
773
+ // (a restart, an arrival whose eager commit failed, or an already-processed
774
+ // message whose entry was pruned) skips the flag mark, which is a cosmetic
775
+ // IMAP flag, never a delivery guarantee. `markMailboxProcessed` prunes an
776
+ // entry once its mark completes, so the map holds only messages awaiting the
777
+ // flag mark rather than growing for the deployment's life.
778
+ const mailboxUidByMessageId = new Map();
779
+ // Serializes every mailbox mutation (lazy construction, the arrival
780
+ // append+flush, the dispatch flag mark) so concurrent arrivals and a fire-and-
781
+ // forget flag mark never interleave against the shared in-memory mirror.
782
+ let mailboxTail = Promise.resolve();
783
+ function runMailboxExclusive(fn) {
784
+ const run = mailboxTail.then(fn, fn);
785
+ mailboxTail = run.then(() => undefined, () => undefined);
786
+ return run;
787
+ }
788
+ async function getMailboxStore() {
789
+ if (mailboxStore === null) {
790
+ mailboxStore = await createSubstrateMailboxStore({
791
+ substrate: bindings.repoStore,
792
+ repoId: bindings.workflowRunRepoId,
793
+ principal: mailboxWritePrincipal,
794
+ ref: bindings.workflowRunRef,
795
+ });
796
+ }
797
+ return mailboxStore;
798
+ }
799
+ function storedEnvelopeFromHeaders(headers, receivedAt) {
800
+ // The Date header is unvalidated external input; fall back to the arrival
801
+ // time when it is absent or unparseable so the store's `toISOString`
802
+ // serialization cannot throw on an Invalid Date.
803
+ const parsed = new Date(headers.date);
804
+ const date = Number.isNaN(parsed.getTime()) ? new Date(receivedAt) : parsed;
805
+ return {
806
+ messageId: headers.messageId,
807
+ from: headers.from,
808
+ to: headers.to,
809
+ subject: headers.subject ?? "",
810
+ date,
811
+ inReplyTo: headers.inReplyTo,
812
+ references: headers.references ?? [],
813
+ interchangeType: headers.interchangeType,
814
+ interchangeCorrelationId: headers.interchangeCorrelationId,
815
+ };
816
+ }
817
+ /**
818
+ * Eager-commit one freshly-arrived inbound message into the deployment's
819
+ * substrate mailbox, then notify the child. Runs on the mail-arrival path,
820
+ * before and independent of FIFO dispatch, so the warm agent's `mail_wait`
821
+ * observes the message mid-turn. Best-effort: the claim-check inbox is the
822
+ * durable delivery contract, so a decode or substrate fault here is logged
823
+ * loudly and never withholds the mail's ack -- the message still reaches the
824
+ * agent as its turn's step input via `trigger.fire`. The `mailbox.notify` is
825
+ * sent only AFTER the append is flushed, so the child reads committed state.
826
+ */
827
+ async function commitInboundToMailbox(messageId, rawMessage, receivedAt) {
828
+ try {
829
+ await runMailboxExclusive(async () => {
830
+ // The caller gates on a fresh `enqueued` outcome, so a redelivery never
831
+ // reaches here; this guard is belt-and-suspenders against a double
832
+ // append of the same messageId.
833
+ if (mailboxUidByMessageId.has(messageId))
834
+ return;
835
+ let decoded;
836
+ try {
837
+ decoded = decodeMail(rawMessage);
838
+ }
839
+ catch (cause) {
840
+ const message = cause instanceof Error ? cause.message : String(cause);
841
+ logger.error `eager mailbox commit: dropping undecodable inbound mail ${messageId}: ${message}`;
842
+ return;
843
+ }
844
+ const store = await getMailboxStore();
845
+ const uid = store.append(rawMessage, storedEnvelopeFromHeaders(decoded.headers, receivedAt), []);
846
+ mailboxUidByMessageId.set(messageId, uid);
847
+ await store.flush();
848
+ const commit = await bindings.repoStore.resolveRef(mailboxWritePrincipal, bindings.workflowRunRepoId, bindings.workflowRunRef);
849
+ if (commit === null) {
850
+ logger.error `eager mailbox commit: ${bindings.workflowRunRef} did not resolve after flush; skipping mailbox.notify for ${messageId}`;
851
+ return;
852
+ }
853
+ const sender = activeControlSender();
854
+ if (sender === null) {
855
+ logger.info `eager mailbox commit: no active control sender; committed ${messageId} as uid ${String(uid)} without mailbox.notify`;
856
+ return;
857
+ }
858
+ await sender.send({
859
+ type: "mailbox.notify",
860
+ data: {
861
+ runId: deriveWorkflowRunId(bindings.deploymentMailAddress),
862
+ mailbox: MAILBOX_INBOX_DIR,
863
+ uid,
864
+ headers: decoded.headers,
865
+ },
866
+ });
867
+ });
868
+ }
869
+ catch (cause) {
870
+ const message = cause instanceof Error ? cause.message : String(cause);
871
+ logger.error `eager mailbox commit failed for ${messageId}; mail still delivered via claim-check dispatch: ${message}`;
872
+ }
873
+ }
874
+ /**
875
+ * Flag a dispatched message's mailbox entry \Seen/$Processed. Fire-and-forget
876
+ * off the dispatch critical path: the flag is a cosmetic IMAP marker, so a
877
+ * missing uid (no eager mailbox entry) or a substrate fault is logged and
878
+ * dropped, never failing the turn.
879
+ */
880
+ function markMailboxProcessed(messageId) {
881
+ const uid = mailboxUidByMessageId.get(messageId);
882
+ if (uid === undefined)
883
+ return;
884
+ void runMailboxExclusive(async () => {
885
+ try {
886
+ const store = await getMailboxStore();
887
+ if (store.find(uid) === undefined)
888
+ return;
889
+ store.addFlags(uid, [MAILBOX_FLAG_SEEN, MAILBOX_FLAG_PROCESSED]);
890
+ await store.flush();
891
+ }
892
+ finally {
893
+ // The id->uid mapping exists only to flag this message once. After the
894
+ // mark runs (or the message is already gone), the entry is dead weight,
895
+ // so drop it to bound the map over a long-lived conversational mailbox.
896
+ // Redelivery dedup is owned by the durable inbox index, not this map.
897
+ // The delete runs inside the exclusive section so it never interleaves
898
+ // with the arrival path's `has(messageId)` check.
899
+ mailboxUidByMessageId.delete(messageId);
900
+ }
901
+ }).catch((cause) => {
902
+ const message = cause instanceof Error ? cause.message : String(cause);
903
+ logger.warn `mailbox flag mark failed for ${messageId} (uid ${String(uid)}): ${message}`;
904
+ });
905
+ }
715
906
  // Resolves once the inbound mail is durably accepted (its inbox write landed
716
907
  // or the message was already durably present); rejects when it was not (a
717
908
  // phase where the deployment is not accepting mail, a transient enqueue
@@ -789,6 +980,11 @@ export function createWorkflowSupervisor(bindings) {
789
980
  // the same messageId already drives dispatch. This resolves for both
790
981
  // outcomes: both mean the bytes are durably accounted for, so both ack.
791
982
  if (outcome.outcome === "enqueued") {
983
+ // Eager-commit the fresh message into the per-run mailbox and notify the
984
+ // child BEFORE waking dispatch, so the warm agent's mail_wait can observe
985
+ // it committed. Non-fatal by contract: the enqueue above already secured
986
+ // the durable delivery, so this never withholds the ack.
987
+ await commitInboundToMailbox(messageId, rawMessage, receivedAt);
792
988
  wakeDispatch();
793
989
  }
794
990
  else {
@@ -859,6 +1055,18 @@ export function createWorkflowSupervisor(bindings) {
859
1055
  });
860
1056
  continue;
861
1057
  }
1058
+ if (payload.type === "mailbox.mutate.request") {
1059
+ // INBOUND half of mailbox ownership (§3b). The child asked the
1060
+ // supervisor -- the sole mailbox writer -- to apply a flag write or
1061
+ // expunge. Run it off the iterator's loop so the iterator keeps
1062
+ // draining while the store flushes; the handler owns the
1063
+ // `mailbox.mutate.response` reply that resolves the child's awaiter.
1064
+ void handleMailboxMutation(payload.data).catch((cause) => {
1065
+ const message = cause instanceof Error ? cause.message : String(cause);
1066
+ logger.error `mailbox.mutate.request handler crashed: ${message}`;
1067
+ });
1068
+ continue;
1069
+ }
862
1070
  if (payload.type === "terminal.event") {
863
1071
  // The workflow-process child mirrors every terminal-run commit
864
1072
  // over the control IPC. Fan it out to the COHORT'S broadcaster
@@ -1193,6 +1401,111 @@ export function createWorkflowSupervisor(bindings) {
1193
1401
  });
1194
1402
  }
1195
1403
  }
1404
+ /**
1405
+ * Apply a child-requested mailbox mutation to the owned store (INBOUND
1406
+ * half of mailbox ownership, §3b). The supervisor is the sole writer to
1407
+ * the workflow-run mailbox; the child never flushes it. A flag write
1408
+ * (`addFlags` / `removeFlags`) targets one uid; an `expunge` sweeps every
1409
+ * `\Deleted` message out of the live INBOX. The mutation is applied under
1410
+ * `runMailboxExclusive` and flushed before the reply, so the child's next
1411
+ * committed read observes it -- the flush-before-signal ordering
1412
+ * `commitInboundToMailbox` uses. A failure (unknown uid, wrong mailbox,
1413
+ * substrate fault) surfaces back as a structured `{ ok: false, reason }`
1414
+ * so the agent's mail-tool call fails loudly rather than dropping the
1415
+ * mutation silently.
1416
+ */
1417
+ async function handleMailboxMutation(data) {
1418
+ // Capture the sender once. Re-fetching after the flush could return a
1419
+ // successor cohort's sender and misroute the reply to the wrong child
1420
+ // (see the substrate-write handler's note). A null sender means the
1421
+ // supervisor is mid-recycle or tearing down: there is nothing to reply
1422
+ // on, so drop and warn -- the child's read end is closing alongside, so
1423
+ // its pending awaiter is rejected by the control loop's `cancelAll`.
1424
+ const controlSender = activeControlSender();
1425
+ if (controlSender === null) {
1426
+ logger.warn `mailbox.mutate.request received outside running phase; requestId=${data.requestId} dropped (child awaiter will fail on pipe close)`;
1427
+ return;
1428
+ }
1429
+ // The supervisor owns exactly one mailbox, the substrate INBOX. Reject a
1430
+ // request for any other name rather than silently mutate INBOX under it,
1431
+ // which would be a wrong-target durable write reported as success. The
1432
+ // frame carries an unconstrained mailbox string, so this is validated
1433
+ // here at the owning layer, not trusted from the child transport.
1434
+ if (data.mailbox !== MAILBOX_INBOX_DIR) {
1435
+ await controlSender.send({
1436
+ type: "mailbox.mutate.response",
1437
+ data: {
1438
+ requestId: data.requestId,
1439
+ result: {
1440
+ ok: false,
1441
+ reason: `unknown mailbox "${data.mailbox}"; only ${MAILBOX_INBOX_DIR} is writable`,
1442
+ },
1443
+ },
1444
+ });
1445
+ return;
1446
+ }
1447
+ try {
1448
+ const expungedUids = await runMailboxExclusive(async () => {
1449
+ const store = await getMailboxStore();
1450
+ if (data.op === "expunge") {
1451
+ // Snapshot the \Deleted uids before removing: `store.messages` is
1452
+ // the live array, so `.filter().map()` materializes the targets
1453
+ // before any `remove` splices it. The whole sweep runs
1454
+ // synchronously under the lock, so no snapshotted uid can vanish
1455
+ // before its `remove`.
1456
+ const uids = store.messages
1457
+ .filter((m) => m.flags.has(MAILBOX_FLAG_DELETED))
1458
+ .map((m) => m.uid);
1459
+ for (const uid of uids) {
1460
+ store.remove(uid);
1461
+ // Bound the id->uid map: drop any entry now pointing at a removed
1462
+ // uid. Not load-bearing -- `markMailboxProcessed` guards with
1463
+ // `find` -- but keeps the map from retaining dead uids.
1464
+ for (const [messageId, mappedUid] of mailboxUidByMessageId) {
1465
+ if (mappedUid === uid)
1466
+ mailboxUidByMessageId.delete(messageId);
1467
+ }
1468
+ }
1469
+ await store.flush();
1470
+ return uids;
1471
+ }
1472
+ if (data.op === "addFlags") {
1473
+ store.addFlags(data.uid, data.flags);
1474
+ }
1475
+ else {
1476
+ store.removeFlags(data.uid, data.flags);
1477
+ }
1478
+ await store.flush();
1479
+ return undefined;
1480
+ });
1481
+ await controlSender.send({
1482
+ type: "mailbox.mutate.response",
1483
+ data: {
1484
+ requestId: data.requestId,
1485
+ result: expungedUids === undefined
1486
+ ? { ok: true }
1487
+ : { ok: true, expungedUids },
1488
+ },
1489
+ });
1490
+ }
1491
+ catch (cause) {
1492
+ // Reply on the same captured sender. If this send itself throws (a
1493
+ // broken pipe), it propagates to the pump's `.catch`, and the child's
1494
+ // awaiter is rejected by the control loop's `cancelAll` -- the backstop
1495
+ // `handleOutboundMessage` also relies on. Accepted window: a mutation
1496
+ // can flush durably while its reply is undeliverable, so the agent tool
1497
+ // errors on a mutation that landed. This is inherent to apply-then-reply
1498
+ // across a teardown boundary and identical to `handleOutboundMessage`.
1499
+ const reason = cause instanceof Error ? cause.message : String(cause);
1500
+ await controlSender.send({
1501
+ type: "mailbox.mutate.response",
1502
+ data: {
1503
+ requestId: data.requestId,
1504
+ result: { ok: false, reason },
1505
+ },
1506
+ });
1507
+ }
1508
+ }
1196
1509
  async function handleSubstrateWriteRequest(data) {
1197
1510
  const controlSender = activeControlSender();
1198
1511
  if (controlSender === null) {
@@ -1491,6 +1804,7 @@ export function createWorkflowSupervisor(bindings) {
1491
1804
  terminalBroadcaster: createTerminalBroadcaster(),
1492
1805
  dispatchLoop: null,
1493
1806
  replayDone: null,
1807
+ sweepDone: null,
1494
1808
  };
1495
1809
  // Everything from here to the successful `return` runs with the state
1496
1810
  // record in "starting" (then "running"). A throw at any of these
@@ -1523,8 +1837,13 @@ export function createWorkflowSupervisor(bindings) {
1523
1837
  // first `dequeueToProcessing` so a fresh inbound mail that lands
1524
1838
  // during the replay window cannot ship ahead of the orphan once
1525
1839
  // the replay completes.
1526
- const replayDone = readOwnedMessageIds(bindings.repoStore, bindings.workflowRunRepoId)
1527
- .then((ownedMessageIds) => inboxPrimitives.replayProcessingToInbox(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, bindings.deploymentMailAddress, { ownedMessageIds }))
1840
+ // One scan of `runs/` feeds both spawn-time recovery consumers: the
1841
+ // orphan replay (which gates dispatch) and the compaction sweep (which
1842
+ // does not). Sharing the walk keeps recovery off a second O(total-runs)
1843
+ // scan.
1844
+ const scanDone = scanRunsForBoot(bindings.repoStore, bindings.workflowRunRepoId);
1845
+ const replayDone = scanDone
1846
+ .then(({ ownedMessageIds }) => inboxPrimitives.replayProcessingToInbox(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, bindings.deploymentMailAddress, { ownedMessageIds }))
1528
1847
  .then(() => {
1529
1848
  wakeDispatch();
1530
1849
  })
@@ -1542,7 +1861,7 @@ export function createWorkflowSupervisor(bindings) {
1542
1861
  // sweep that picks up parked orphans. Left as logged
1543
1862
  // best-effort until that lands.
1544
1863
  const message = cause instanceof Error ? cause.message : String(cause);
1545
- logger.warn `replayProcessingToInbox on spawn failed: ${message}`;
1864
+ logger.warn `boot recovery scan or processing replay failed on spawn: ${message}`;
1546
1865
  });
1547
1866
  // Hold the replay promise on the active-state record so
1548
1867
  // `shutdownInternal` awaits its settlement before tearing the
@@ -1550,6 +1869,36 @@ export function createWorkflowSupervisor(bindings) {
1550
1869
  // flight would otherwise leave the substrate write pending past
1551
1870
  // the supervisor's exit.
1552
1871
  state.replayDone = replayDone;
1872
+ // Re-seal runs a crash left terminal-but-per-event when their
1873
+ // fire-and-forget fold never ran. Unlike the replay above, this must
1874
+ // NOT gate dispatch: reclaiming leaked per-event files is housekeeping
1875
+ // and cannot be allowed to delay the first dequeue. Best-effort, held
1876
+ // on the active-state record so shutdown awaits its settlement (see the
1877
+ // `sweepDone` field docstring for the teardown-latency tradeoff).
1878
+ const sweepDone = scanDone
1879
+ .then(({ pendingSealRunIds }) => recoverInterruptedCompactions({
1880
+ substrate: bindings.repoStore,
1881
+ repoId: bindings.workflowRunRepoId,
1882
+ ref: bindings.workflowRunRef,
1883
+ anchorRunId: bindings.anchorRunId,
1884
+ pendingSealRunIds,
1885
+ }))
1886
+ .then(({ sealed, failed }) => {
1887
+ if (sealed > 0) {
1888
+ logger.info `recovery sweep sealed ${String(sealed)} interrupted run(s)`;
1889
+ }
1890
+ if (failed.length > 0) {
1891
+ const detail = failed
1892
+ .map((f) => `${f.runId} (${f.message})`)
1893
+ .join("; ");
1894
+ logger.warn `recovery sweep left ${String(failed.length)} run(s) unsealed: ${detail}`;
1895
+ }
1896
+ })
1897
+ .catch((cause) => {
1898
+ const message = cause instanceof Error ? cause.message : String(cause);
1899
+ logger.warn `boot recovery scan or compaction sweep failed on spawn: ${message}`;
1900
+ });
1901
+ state.sweepDone = sweepDone;
1553
1902
  bindings.mailBus.registerAddress(bindings.deploymentMailAddress);
1554
1903
  const mailUnsubscribe = bindings.mailBus.subscribeMailForAddress(bindings.deploymentMailAddress, onMailMessage);
1555
1904
  state.mailUnsubscribe = mailUnsubscribe;
@@ -1620,6 +1969,26 @@ export function createWorkflowSupervisor(bindings) {
1620
1969
  },
1621
1970
  });
1622
1971
  }
1972
+ // Deliver the run's credential material to the child on EVERY spawn, not
1973
+ // only through the per-trigger `onRunStart` barrier. A restored run resumes
1974
+ // from its parked state without a fresh `trigger.fire` (a signal wakes it),
1975
+ // so the barrier would never re-deliver the cell and the resumed run's
1976
+ // inference would fail closed. Seeding the live cell here at spawn lets an
1977
+ // offline restart resolve each source's credential from the persisted
1978
+ // (unsealed) delivery without waiting on a hub reconnect. Unlike the grants
1979
+ // push above this is NOT suppressed when `onRunStart` is wired: the barrier
1980
+ // fires per trigger, but a resume has no trigger, so the spawn push is the
1981
+ // only credential source on the resume path. Idempotent with the barrier's
1982
+ // own push on a fresh run: the child merges both, and both carry the same
1983
+ // mirror. Absent when the deployment binds no credentials. Reads the live
1984
+ // mirror, not the frozen deploy delivery, so a credential revoked earlier
1985
+ // this process stays evicted.
1986
+ if (currentCredentialDelivery !== null) {
1987
+ await wired.wiring.controlSender.send({
1988
+ type: "credentials-updated",
1989
+ data: { delivery: currentCredentialDelivery },
1990
+ });
1991
+ }
1623
1992
  // Transition to running. The dispatch loop (started below)
1624
1993
  // picks up any pre-ready buffered mail through the FIFO inbox
1625
1994
  // queue rather than through an in-memory buffer; arrival order
@@ -1652,6 +2021,7 @@ export function createWorkflowSupervisor(bindings) {
1652
2021
  terminalBroadcaster: startingPhaseBroadcaster,
1653
2022
  dispatchLoop,
1654
2023
  replayDone,
2024
+ sweepDone,
1655
2025
  };
1656
2026
  // Bump the generation and arm the exit-watcher atomically with the
1657
2027
  // running transition (no await between the swap above and this call)
@@ -1820,22 +2190,88 @@ export function createWorkflowSupervisor(bindings) {
1820
2190
  * Forward one dequeued inbox entry to the child as `trigger.fire`
1821
2191
  * and record its runId as in-flight. The runId is the local part of the
1822
2192
  * deployment's mail address (see `deriveWorkflowRunId`), identifying its one
1823
- * top-level run; the `messageId` rides alongside it so the child can
1824
- * recover the trigger's mail bytes by claim-check. The runId is the
1825
- * same value the dispatch loop waits on via `terminalEventSource`.
2193
+ * top-level run. The resolved `Mail` (headers plus committed part references)
2194
+ * rides in the frame as the run's trigger payload; the `messageId`
2195
+ * accompanies it for correlation and audit. The runId is the same value the
2196
+ * dispatch loop waits on via `terminalEventSource`.
1826
2197
  */
1827
- async function forwardDispatchedEntry(sender, messageId, receivedAt, runId) {
2198
+ async function forwardDispatchedEntry(sender, messageId, receivedAt, runId, payload) {
1828
2199
  await sender.send({
1829
2200
  type: "trigger.fire",
1830
2201
  data: {
1831
2202
  runId,
1832
2203
  messageId,
1833
2204
  receivedAt,
2205
+ payload,
1834
2206
  },
1835
2207
  });
1836
2208
  cohortRunIds.add(runId);
1837
2209
  return runId;
1838
2210
  }
2211
+ /**
2212
+ * Resolve a dequeued inbound mail to the run's input: a decoded `Mail`
2213
+ * (headers plus part descriptors that reference the part bytes committed to
2214
+ * the workflow-run substrate). The supervisor is the sole mail owner and
2215
+ * commits the parts here (a direct workflow-run write; the workflow child's
2216
+ * control loop cannot do a synchronous proxied write without deadlock), so
2217
+ * both turns share this one preparation site.
2218
+ *
2219
+ * The two failure modes are deliberately distinct:
2220
+ * - A DETERMINISTIC input rejection -- missing bytes, unparseable MIME, or
2221
+ * a messageId that cannot form a path segment -- returns `{ ok: false }`
2222
+ * so the caller drops the mail. Replaying it would fail identically.
2223
+ * - A TRANSIENT substrate write failure propagates (thrown), so the caller
2224
+ * treats it as a dispatch fault and leaves the mail reclaimable rather
2225
+ * than silently discarding it on an infrastructure hiccup.
2226
+ */
2227
+ async function prepareMail(envelope, runId) {
2228
+ if (envelope.rawMessage === undefined) {
2229
+ return {
2230
+ ok: false,
2231
+ rejection: {
2232
+ code: "malformed_mail",
2233
+ message: `inbound mail ${envelope.messageId} carries no rawMessage bytes`,
2234
+ },
2235
+ };
2236
+ }
2237
+ let decoded;
2238
+ try {
2239
+ decoded = decodeMail(base64Decode(envelope.rawMessage));
2240
+ }
2241
+ catch (cause) {
2242
+ const message = cause instanceof Error ? cause.message : String(cause);
2243
+ return {
2244
+ ok: false,
2245
+ rejection: {
2246
+ code: "malformed_mail",
2247
+ message: `inbound mail ${envelope.messageId} could not be decoded: ${message}`,
2248
+ },
2249
+ };
2250
+ }
2251
+ const writePrincipal = {
2252
+ kind: "supervisor",
2253
+ anchorRunId: bindings.anchorRunId,
2254
+ };
2255
+ try {
2256
+ const mail = await commitMail({
2257
+ substrate: bindings.repoStore,
2258
+ repoId: bindings.workflowRunRepoId,
2259
+ principal: writePrincipal,
2260
+ runId,
2261
+ ref: bindings.workflowRunRef,
2262
+ }, envelope.messageId, decoded);
2263
+ return { ok: true, mail };
2264
+ }
2265
+ catch (cause) {
2266
+ if (cause instanceof InvalidMailError) {
2267
+ return {
2268
+ ok: false,
2269
+ rejection: { code: "malformed_mail", message: cause.message },
2270
+ };
2271
+ }
2272
+ throw cause;
2273
+ }
2274
+ }
1839
2275
  /**
1840
2276
  * Push the run's grants snapshot to the child ahead of its
1841
2277
  * `trigger.fire`. Returns `true` if the barrier FAILED (the caller must
@@ -1874,13 +2310,15 @@ export function createWorkflowSupervisor(bindings) {
1874
2310
  });
1875
2311
  // Deliver the deployment's credential material on the same pre-trigger
1876
2312
  // barrier, so a tool that resolves a credential on the first step already
1877
- // has it in the child's cell. The material is the decrypted delivery the
1878
- // hub put on the deploy frame; a later rotation flows through
1879
- // `deliverCredentials` instead. Absent when the deployment binds none.
1880
- if (bindings.credentialDelivery !== undefined) {
2313
+ // has it in the child's cell. Reads the live mirror, not the frozen deploy
2314
+ // delivery: a rotation or revocation delivered earlier via
2315
+ // `deliverCredentials` is reflected here, and a recycled child (seeded only
2316
+ // by this barrier) inherits the current set instead of the deploy-time one.
2317
+ // Absent when the deployment binds none.
2318
+ if (currentCredentialDelivery !== null) {
1881
2319
  await sender.send({
1882
2320
  type: "credentials-updated",
1883
- data: { delivery: bindings.credentialDelivery },
2321
+ data: { delivery: currentCredentialDelivery },
1884
2322
  });
1885
2323
  }
1886
2324
  return false;
@@ -2023,30 +2461,24 @@ export function createWorkflowSupervisor(bindings) {
2023
2461
  }
2024
2462
  const inputChannel = runInputChannels.get(runId);
2025
2463
  if (inputChannel !== undefined) {
2026
- // Resolve the inbound mail to conversation text HERE, the single
2027
- // site that knows this payload's provenance is mail, applying the
2028
- // SAME extraction the turn-1 trigger does (resolveTriggerPayload).
2029
- // The signal.deliver frame's payload is the resume decision in FINAL
2030
- // form; deliverSignal's structured signals ship their own payload
2031
- // unchanged. Done BEFORE minting the terminal watcher so a failure
2032
- // here cannot leak an un-finalized iterator.
2033
- let inputText;
2034
- try {
2035
- if (envelope.rawMessage === undefined) {
2036
- throw new Error("inbound mail carries no rawMessage bytes");
2037
- }
2038
- inputText = extractConversationText(base64Decode(envelope.rawMessage), envelope.messageId);
2039
- }
2040
- catch (cause) {
2041
- // A malformed turn-2 mail cannot resume the parked agent. DROP it:
2042
- // log loudly and consume it (break to the post-loop markConsumed)
2043
- // rather than throwing -- a throw aborts the dispatch without
2044
- // consuming, and replay re-delivers the same poison mail forever.
2045
- // The run stays parked on its current correlation, ready for the
2046
- // next valid mail; one bad mail must not tear down a long-lived
2047
- // conversation.
2048
- const message = cause instanceof Error ? cause.message : String(cause);
2049
- logger.error `signal.deliver for run ${runId}: dropping malformed inbound mail ${envelope.messageId}: ${message}`;
2464
+ // Resolve the inbound mail to the run's input HERE, the single site
2465
+ // that knows this payload's provenance is mail, applying the SAME
2466
+ // preparation the turn-1 trigger does. The signal.deliver frame's
2467
+ // payload is the resume decision in FINAL form -- a Mail (headers plus committed part references); deliverSignal's structured signals ship their own
2468
+ // payload unchanged. Done BEFORE minting the terminal watcher so a
2469
+ // failure here cannot leak an un-finalized iterator.
2470
+ const prepared = await prepareMail(envelope, runId);
2471
+ if (!prepared.ok) {
2472
+ // A DETERMINISTICALLY malformed turn-2 mail cannot resume the
2473
+ // parked agent. DROP it: log loudly and consume it (break to the
2474
+ // post-loop markConsumed) rather than throwing -- replay would
2475
+ // re-deliver the same poison mail forever. The run stays parked
2476
+ // on its current correlation, ready for the next valid mail; one
2477
+ // bad mail must not tear down a long-lived conversation. A
2478
+ // TRANSIENT write failure is NOT caught here: `prepareMail`
2479
+ // throws it, so it propagates as a dispatch fault and the mail
2480
+ // stays reclaimable for retry.
2481
+ logger.error `signal.deliver for run ${runId}: dropping malformed inbound mail ${envelope.messageId}: ${prepared.rejection.message}`;
2050
2482
  break;
2051
2483
  }
2052
2484
  // Mint the terminal watcher only now, after the payload resolved, so
@@ -2062,7 +2494,7 @@ export function createWorkflowSupervisor(bindings) {
2062
2494
  runId,
2063
2495
  signalName: signalName(inputChannel.correlationId),
2064
2496
  signalId: envelope.messageId,
2065
- payload: inputText,
2497
+ payload: prepared.mail,
2066
2498
  },
2067
2499
  });
2068
2500
  // Invalidate the cached input channel: its correlation is now
@@ -2073,6 +2505,9 @@ export function createWorkflowSupervisor(bindings) {
2073
2505
  // delivering onto the stale channel. Routing hygiene only -- the
2074
2506
  // wait keys on the park-generation edge, not this level state.
2075
2507
  runInputChannels.delete(runId);
2508
+ // The message was dispatched as a turn: mark its eager mailbox
2509
+ // entry \Seen/$Processed. Fire-and-forget off the dispatch path.
2510
+ markMailboxProcessed(envelope.messageId);
2076
2511
  // Durable-consume contract, mirroring the trigger.fire path: hold
2077
2512
  // markConsumed until the child has durably taken up the signal --
2078
2513
  // the resumed run re-parks or reaches a terminal event. That gate
@@ -2103,6 +2538,23 @@ export function createWorkflowSupervisor(bindings) {
2103
2538
  break;
2104
2539
  }
2105
2540
  if (!cohortRunIds.has(runId)) {
2541
+ // Resolve the inbound mail to the run's input before firing. A
2542
+ // DETERMINISTICALLY malformed first trigger cannot start the run:
2543
+ // record the rejection on the consumed entry and drop it (break to
2544
+ // the post-loop markConsumed), since replay would fail identically.
2545
+ // A TRANSIENT write failure instead propagates from
2546
+ // `prepareMail` as a dispatch fault, leaving the mail
2547
+ // reclaimable. Unlike a turn-2 parse failure (which leaves a live
2548
+ // run parked), a malformed first trigger produces no run at all --
2549
+ // the rejection surfaces on the consumed entry, not as a RunFailed
2550
+ // terminal event.
2551
+ const prepared = await prepareMail(envelope, runId);
2552
+ if (!prepared.ok) {
2553
+ if (rejection === undefined)
2554
+ rejection = prepared.rejection;
2555
+ logger.error `trigger.fire for run ${runId}: rejecting malformed inbound mail ${envelope.messageId}: ${prepared.rejection.message}`;
2556
+ break;
2557
+ }
2106
2558
  // Subscribe the terminal watcher BEFORE the trigger fires. The
2107
2559
  // broadcaster drops a notify that has no listener (its subscribe-
2108
2560
  // before-fire contract), so a terminal that lands while
@@ -2111,13 +2563,20 @@ export function createWorkflowSupervisor(bindings) {
2111
2563
  const iter = broadcaster.source(runId)[Symbol.asyncIterator]();
2112
2564
  let waitEntered = false;
2113
2565
  try {
2114
- await forwardDispatchedEntry(sender, envelope.messageId, envelope.receivedAt, runId);
2115
- // Wait for the child to process this trigger before allowing
2566
+ await forwardDispatchedEntry(sender, envelope.messageId, envelope.receivedAt, runId, prepared.mail);
2567
+ // The message was dispatched as a turn: mark its eager mailbox
2568
+ // entry \Seen/$Processed. Fire-and-forget off the dispatch path.
2569
+ markMailboxProcessed(envelope.messageId);
2570
+ // Wait for the child to durably take up this trigger (RunStarted
2571
+ // committed, then the run parks or terminates) before allowing
2116
2572
  // `markConsumed` to move the claim-check entry out of
2117
- // `processing/`. The child reads the trigger payload from that
2118
- // entry; racing `markConsumed` would delete the entry before the
2119
- // child resolves it. On cohort abort the wait returns and the
2120
- // post-loop guard skips markConsumed.
2573
+ // `processing/`. The payload now rides the frame, so the child no
2574
+ // longer reads it from the entry -- but the durable-consume
2575
+ // contract still holds markConsumed until the run's uptake is
2576
+ // committed, so a crash before RunStarted leaves the entry in
2577
+ // processing/ for replayProcessingToInbox to re-deliver. On cohort
2578
+ // abort the wait returns and the post-loop guard skips
2579
+ // markConsumed.
2121
2580
  waitEntered = true;
2122
2581
  await waitForRunTerminalOrPark(iter, cohortAbort.signal, runId, sinceGen);
2123
2582
  }
@@ -2478,6 +2937,21 @@ export function createWorkflowSupervisor(bindings) {
2478
2937
  path only waits for the substrate write to settle. */
2479
2938
  });
2480
2939
  }
2940
+ if ((prior.phase === "starting" ||
2941
+ prior.phase === "running" ||
2942
+ prior.phase === "recycling") &&
2943
+ prior.sweepDone !== null) {
2944
+ // Await the spawn-time compaction sweep before teardown so an
2945
+ // in-flight fold's substrate commit does not outlive the supervisor
2946
+ // and interleave with the next incarnation's boot. Teardown latency
2947
+ // is bounded by the recovery backlog (see the `sweepDone` field
2948
+ // docstring); a normal boot has zero or one pending fold.
2949
+ await prior.sweepDone.catch(() => {
2950
+ /* swallowed: the sweep's own catch already surfaces failures to
2951
+ the supervisor's warn channel; the shutdown path only waits for
2952
+ the in-flight fold's substrate commit to settle. */
2953
+ });
2954
+ }
2481
2955
  if (recyclePolicy !== null) {
2482
2956
  try {
2483
2957
  recyclePolicy.stop();
@@ -2555,6 +3029,26 @@ export function createWorkflowSupervisor(bindings) {
2555
3029
  }
2556
3030
  state = { phase: opts.terminalPhase ?? "stopped" };
2557
3031
  }
3032
+ // Surface a self-termination to the host after the terminal transition is
3033
+ // committed. The already-terminal early-return at the top dedups the common
3034
+ // case, but it does NOT cover the `stopping` window, so two self-terminating
3035
+ // callers interleaving through teardown can each fire (e.g. an onChildCrash
3036
+ // during `recycling` plus the recycle-failure catch). The sink is therefore
3037
+ // idempotent-required, not exactly-once; the reclaim it drives absorbs a
3038
+ // repeat by design. Wrapped so a throwing sink cannot re-escape here and
3039
+ // break the documented shutdown totality.
3040
+ if (opts.selfTerminated === true) {
3041
+ try {
3042
+ bindings.onSelfTerminate?.({
3043
+ phase: opts.terminalPhase ?? "stopped",
3044
+ reason: opts.reason,
3045
+ });
3046
+ }
3047
+ catch (cause) {
3048
+ const message = cause instanceof Error ? cause.message : String(cause);
3049
+ logger.warn `onSelfTerminate sink threw: ${message}`;
3050
+ }
3051
+ }
2558
3052
  logger.info `supervisor shutdown complete (${opts.reason})`;
2559
3053
  }
2560
3054
  async function drain(opts) {
@@ -2711,6 +3205,7 @@ export function createWorkflowSupervisor(bindings) {
2711
3205
  terminalBroadcaster: prior.terminalBroadcaster,
2712
3206
  dispatchLoop: null,
2713
3207
  replayDone: null,
3208
+ sweepDone: prior.sweepDone,
2714
3209
  };
2715
3210
  let attempt;
2716
3211
  try {
@@ -2826,6 +3321,7 @@ export function createWorkflowSupervisor(bindings) {
2826
3321
  terminalBroadcaster: newBroadcaster,
2827
3322
  dispatchLoop: newDispatchLoop,
2828
3323
  replayDone: null,
3324
+ sweepDone: prior.sweepDone,
2829
3325
  };
2830
3326
  // Bump the generation and arm the exit-watcher for the
2831
3327
  // respawned child atomically with this running transition, so
@@ -2915,6 +3411,7 @@ export function createWorkflowSupervisor(bindings) {
2915
3411
  logger.error `recycle failed; tearing supervisor down: ${message}`;
2916
3412
  await shutdownInternal({
2917
3413
  reason: `recycle failed: ${message}`,
3414
+ selfTerminated: true,
2918
3415
  }).catch((shutdownCause) => {
2919
3416
  const inner = shutdownCause instanceof Error
2920
3417
  ? shutdownCause.message
@@ -2950,6 +3447,27 @@ export function createWorkflowSupervisor(bindings) {
2950
3447
  if (state.phase !== "running" && state.phase !== "starting") {
2951
3448
  throw new Error(`supervisor: deliverSignal called in phase ${state.phase}; expected starting/running`);
2952
3449
  }
3450
+ // Refresh the run's grant floor on the SAME control channel immediately
3451
+ // before the signal, so a standing ("always") approval resolved for a
3452
+ // parked run lowers the floor for the resumed run's later calls. Ordering
3453
+ // is structural: both frames ride this single seq-ordered FIFO, so the
3454
+ // `grants-updated` is observed by the child ahead of the `signal.deliver`
3455
+ // -- no dependence on hub-side dispatch timing. Best-effort by design; a
3456
+ // failed refresh is non-fatal (the durable file still governs the next
3457
+ // barrier), and it only re-reads that file, so a signal with no standing
3458
+ // approval just re-pushes the unchanged floor.
3459
+ await deliverGrants(opts.runId);
3460
+ // `deliverGrants` awaits a substrate read, yielding the event loop. A
3461
+ // crash/recycle can land in that window and swap `state` (its
3462
+ // `controlSender` then points at the dying child). Re-assert the phase the
3463
+ // pre-await guard checked, so the signal is never written into a recycling
3464
+ // child's closing pipe; the caller retries once the recycle completes. The
3465
+ // phase is read through the full union type because the pre-await guard
3466
+ // control-flow-narrowed `state`, which the yield may have invalidated.
3467
+ const phaseAfterRefresh = state.phase;
3468
+ if (phaseAfterRefresh !== "running" && phaseAfterRefresh !== "starting") {
3469
+ throw new Error(`supervisor: deliverSignal raced a recycle in phase ${phaseAfterRefresh}; expected starting/running`);
3470
+ }
2953
3471
  await state.controlSender.send({
2954
3472
  type: "signal.deliver",
2955
3473
  data: {
@@ -2979,18 +3497,83 @@ export function createWorkflowSupervisor(bindings) {
2979
3497
  });
2980
3498
  }
2981
3499
  async function deliverCredentials(opts) {
2982
- // The supervisor is the single producer of `credentials-updated` control
2983
- // frames. Phase-guarded exactly like `deliverSources`: outside
2984
- // starting/running the control sender points at a dying child, so a frame
2985
- // would buffer behind the SIGTERM or write into a closed pipe. Rejecting
2986
- // surfaces the race so the caller can retry once the recycle completes.
3500
+ // Compute the next mirror first. It must advance regardless of phase so the
3501
+ // NEXT spawn/barrier re-asserts this update: a credential revoked while the
3502
+ // supervisor holds no live child (a crash-loop retry, a recycle transient)
3503
+ // must still be gone when the child (re)starts, not resurrected from the
3504
+ // frozen deploy delivery. This is what makes an offline-revoke reconcile
3505
+ // durable even when it arrives at a supervisor without a running child.
3506
+ const next = mergeCredentialDelivery(currentCredentialDelivery, opts.delivery, opts.revoke);
3507
+ // Send to the live child ONLY in starting/running. Outside those the control
3508
+ // sender points at a dying/absent child, so a frame would buffer behind a
3509
+ // SIGTERM or write into a closed pipe; the mirror advance below is the sole
3510
+ // effect, and the child that eventually spawns is seeded from it. The
3511
+ // supervisor is the single producer of `credentials-updated` frames.
3512
+ if (state.phase === "running" || state.phase === "starting") {
3513
+ await state.controlSender.send({
3514
+ type: "credentials-updated",
3515
+ data: {
3516
+ delivery: opts.delivery,
3517
+ ...(opts.revoke !== undefined ? { revoke: opts.revoke } : {}),
3518
+ },
3519
+ });
3520
+ }
3521
+ // Advance the mirror. A throw from `send` above skips this, leaving the
3522
+ // mirror matching the child that never received the frame. A whole-object
3523
+ // swap via the same merge the child applies, so a concurrent reader sees a
3524
+ // coherent object.
3525
+ currentCredentialDelivery = next;
3526
+ }
3527
+ /**
3528
+ * Refresh a live run's grant floor mid-run: re-read this run's durable
3529
+ * `runs/<runId>/grants.json` (via `onRunStart`, the same read the pre-trigger
3530
+ * barrier uses) and push it to the child as a `grants-updated` frame. The
3531
+ * enforcement path for a standing (`scope: "always"`) approval, which lowers
3532
+ * a tool's `ask` to `allow` in that file: the barrier only runs before a
3533
+ * trigger/signal dispatch, so a run already executing (or being resumed
3534
+ * without a fresh barrier) needs this to observe the change now.
3535
+ *
3536
+ * Distinct from `pushRunGrants` on two axes, both deliberate:
3537
+ * - It NEVER synthesizes a `RunFailed`. A refresh for a run whose child is
3538
+ * not live is normal (the durable file already carries the change and the
3539
+ * next barrier or respawn re-reads it), so it no-ops (`skipped`) rather
3540
+ * than failing the run, and a send failure to a live child is logged
3541
+ * loudly but stays non-fatal (the file still wins at the next barrier).
3542
+ * - It only ever pushes the durable file's contents through `onRunStart`; it
3543
+ * accepts no caller-supplied grants, so it can only tighten or refresh a
3544
+ * floor, never inject one a deploy did not approve.
3545
+ */
3546
+ async function deliverGrants(runId) {
3547
+ if (bindings.onRunStart === undefined)
3548
+ return "skipped";
2987
3549
  if (state.phase !== "running" && state.phase !== "starting") {
2988
- throw new Error(`supervisor: deliverCredentials called in phase ${state.phase}; expected starting/running`);
3550
+ return "skipped";
3551
+ }
3552
+ try {
3553
+ const snapshot = await bindings.onRunStart({
3554
+ runId,
3555
+ anchorRunId: bindings.anchorRunId,
3556
+ });
3557
+ await state.controlSender.send({
3558
+ type: "grants-updated",
3559
+ data: {
3560
+ snapshot: {
3561
+ steps: snapshot.steps.map((s) => ({
3562
+ stepId: s.stepId,
3563
+ address: s.address,
3564
+ grants: [...s.grants],
3565
+ contentHash: s.contentHash,
3566
+ })),
3567
+ },
3568
+ },
3569
+ });
3570
+ return "pushed";
3571
+ }
3572
+ catch (cause) {
3573
+ const message = cause instanceof Error ? cause.message : String(cause);
3574
+ logger.error `deliverGrants refresh failed for run ${runId}; the durable grants file still governs the next barrier/respawn: ${message}`;
3575
+ return "skipped";
2989
3576
  }
2990
- await state.controlSender.send({
2991
- type: "credentials-updated",
2992
- data: { delivery: opts.delivery },
2993
- });
2994
3577
  }
2995
3578
  function getCredentialsSnapshot() {
2996
3579
  if (state.phase === "starting" || state.phase === "running") {
@@ -3007,6 +3590,7 @@ export function createWorkflowSupervisor(bindings) {
3007
3590
  deliverSignal,
3008
3591
  deliverSources,
3009
3592
  deliverCredentials,
3593
+ deliverGrants,
3010
3594
  reEmitParkedCorrelations,
3011
3595
  getCredentialsSnapshot,
3012
3596
  };
@@ -3123,6 +3707,8 @@ function outboundMessageFromPayload(payload) {
3123
3707
  message.summary = payload.summary;
3124
3708
  if (payload.inReplyTo !== undefined)
3125
3709
  message.inReplyTo = payload.inReplyTo;
3710
+ if (payload.references !== undefined)
3711
+ message.references = payload.references;
3126
3712
  if (payload.correlationId !== undefined) {
3127
3713
  message.correlationId = payload.correlationId;
3128
3714
  }