@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.
- package/README.md +21 -4
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.js +5 -14
- package/dist/adapters/spawn-child.d.ts +42 -6
- package/dist/adapters/spawn-child.js +8 -18
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +230 -60
- package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
- package/dist/adapters/substrate-mailbox-store.js +404 -0
- package/dist/child/child-mailbox-reader.d.ts +10 -0
- package/dist/child/child-mailbox-reader.js +23 -0
- package/dist/child/credential-cell.d.ts +8 -0
- package/dist/child/credential-cell.js +66 -0
- package/dist/child/from-process-env.d.ts +12 -0
- package/dist/child/from-process-env.js +6 -0
- package/dist/child/index.d.ts +4 -1
- package/dist/child/index.js +4 -1
- package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
- package/dist/child/mailbox-mutation-bridge.js +101 -0
- package/dist/child/mailbox-watch-registry.d.ts +17 -0
- package/dist/child/mailbox-watch-registry.js +61 -0
- package/dist/child/outbound-mail-bridge.d.ts +3 -2
- package/dist/child/outbound-mail-bridge.js +20 -32
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/run-child.d.ts +69 -7
- package/dist/child/run-child.js +307 -75
- package/dist/child/substrate-write-bridge.d.ts +3 -2
- package/dist/child/substrate-write-bridge.js +21 -38
- package/dist/child/supervisor-backed-transport.d.ts +52 -6
- package/dist/child/supervisor-backed-transport.js +205 -62
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/ipc/control-channel.d.ts +93 -2
- package/dist/ipc/control-channel.js +147 -47
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/run-body-then-cleanup.d.ts +17 -0
- package/dist/run-body-then-cleanup.js +38 -0
- package/dist/seams/scheduler.d.ts +12 -0
- package/dist/seams/scheduler.js +13 -4
- package/dist/supervisor/cancel-signing.js +3 -7
- package/dist/supervisor/credentials.d.ts +17 -5
- package/dist/supervisor/recycle.d.ts +5 -1
- package/dist/supervisor/run-event-compaction.d.ts +2 -2
- package/dist/supervisor/run-event-compaction.js +11 -16
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/supervisor.d.ts +27 -4
- package/dist/supervisor/supervisor.js +644 -58
- package/dist/supervisor/terminal-commit.js +3 -7
- package/dist/supervisor/types.d.ts +30 -0
- package/dist/testing/change-notifier.d.ts +12 -0
- package/dist/testing/change-notifier.js +63 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/log-capture.d.ts +52 -0
- package/dist/testing/log-capture.js +124 -0
- package/dist/testing/mail-bus.d.ts +22 -0
- package/dist/testing/mail-bus.js +78 -0
- package/dist/testing/memory-streams.d.ts +43 -0
- package/dist/testing/memory-streams.js +211 -0
- package/dist/testing/spawn-observer.d.ts +12 -0
- package/dist/testing/spawn-observer.js +36 -0
- package/dist/testing/stub-repo-store.d.ts +10 -0
- package/dist/testing/stub-repo-store.js +39 -0
- package/dist/testing/supervisor-reaper.d.ts +24 -0
- package/dist/testing/supervisor-reaper.js +49 -0
- package/dist/testing/upstream-frames.d.ts +47 -0
- package/dist/testing/upstream-frames.js +94 -0
- package/dist/workflow-definition-loader.d.ts +56 -0
- package/dist/workflow-definition-loader.js +106 -0
- package/package.json +17 -11
- package/dist/conversation-text.d.ts +0 -23
- 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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1527
|
-
|
|
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 `
|
|
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
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
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.
|
|
1878
|
-
//
|
|
1879
|
-
// `deliverCredentials`
|
|
1880
|
-
|
|
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:
|
|
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
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
2118
|
-
//
|
|
2119
|
-
//
|
|
2120
|
-
//
|
|
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
|
-
//
|
|
2983
|
-
//
|
|
2984
|
-
//
|
|
2985
|
-
//
|
|
2986
|
-
//
|
|
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
|
-
|
|
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
|
}
|