@worca/app 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,1065 @@
1
+ // src/core/graph/scheduler.mjs
2
+ // The single-owner dataflow loop of the v2 graph engine: the token store, the
3
+ // drain/launch walk, per-wire loop budgets and their human gates, End completion
4
+ // and the resume-v2 snapshot.
5
+ //
6
+ // Shape of a pass: publishing routes tokens IMMEDIATELY (per-wire delivery counting
7
+ // and gate checks happen AT DELIVERY), but FIRING happens only in the drain loop. A
8
+ // pass walks `loops.launchOrder` ONCE; a node ready at its slot fires AT that slot —
9
+ // agent nodes as an async `execute` under the semaphore, flow nodes inline,
10
+ // whose publishes route immediately and may make LATER slots ready within the same
11
+ // pass. Earlier slots are never revisited mid-pass; passes repeat until a pass fires
12
+ // nothing. The execution sequence is therefore the order of `execute` CALLS, and it
13
+ // is deterministic.
14
+ //
15
+ // EVERY node's execution is routed through the injected `execute` — flow kinds
16
+ // inline and outside the semaphore, agent kinds under it. The scheduler never
17
+ // reads an agent key and does no IO.
18
+ //
19
+ // `rerunPending` coalescing is structural: a node that is already running is skipped
20
+ // in the walk, and readiness is re-evaluated after every completion, so readiness
21
+ // reached while running re-fires exactly once and never queues.
22
+ import { FLOW_KINDS, DEFAULT_MAX_CYCLES, AWAIT_PORT } from '../../shared/graph/constants.mjs';
23
+ import { classifyLoops } from '../../shared/graph/loops.mjs';
24
+ import { firedOutputs, resolveOrOutType } from '../../shared/graph/ports.mjs';
25
+ import { blockingIssues, hasBlocking } from '../../shared/graph/verdict.mjs';
26
+
27
+ /** Execution statuses that need no re-invocation on restore. */
28
+ const TERMINAL = new Set(['done', 'error', 'skipped']);
29
+ /** The reserved synthesized gate input: bound for readiness, never a payload, never a mode. */
30
+ const AWAIT_ID = AWAIT_PORT.id;
31
+ /** The §3 completion warning for a run that quiesced without reaching End (A8: ONE literal). */
32
+ export const QUIESCENCE_WARNING = 'finished at quiescence — End not reached';
33
+
34
+ /**
35
+ * The SECOND warning line a quiesced run gets when an output fired into nothing —
36
+ * "finished at quiescence" alone never says WHY. A separate entry, never a longer
37
+ * QUIESCENCE_WARNING: `ui/public/graph/run-decor.mjs` re-derives the base string
38
+ * client-side (`warnings.includes(QUIESCENCE_WARNING)`), so widening it would paint
39
+ * the banner twice.
40
+ * @param {string[]} ids sorted '<nodeId>.<portId>' list
41
+ */
42
+ export const quiescenceDeadEnd = (ids) =>
43
+ `dead-ended output${ids.length === 1 ? '' : 's'}: ${ids.join(', ')} — fired with no wire`;
44
+
45
+ function defaultMaxParallel() {
46
+ const n = Number(process.env.WORCA_MAX_PARALLEL);
47
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 4;
48
+ }
49
+
50
+ /** Non-interactive default: gates continue, clarify asks answer empty (v1 `_ask` auto). */
51
+ const defaultAsk = async (ask) => (ask?.kind === 'gate' ? 'continue' : []);
52
+
53
+ /**
54
+ * The execution id of one composite sub-execution: the parent's id plus the manifest
55
+ * task id (`x:n_impl:1:p1t1`). Deterministic on purpose — a resumed composite
56
+ * re-mints the SAME ids, so its ledger entries overwrite rather than accumulate.
57
+ * @param {string} parentExecutionId
58
+ * @param {string} taskId
59
+ */
60
+ export function sliceExecutionId(parentExecutionId, taskId) {
61
+ return `${parentExecutionId}:${taskId}`;
62
+ }
63
+
64
+ /** An abort rejection — a sibling cancelled by a phase-mate's failure, or the whole
65
+ * run going down. Never counted as the FIRST genuine failure. */
66
+ const isAbortError = (err) => err?.name === 'AbortError';
67
+
68
+ /**
69
+ * Settle with the promise, or reject with the signal's reason the moment it aborts.
70
+ * The scheduler never WAITS on an executor that ignores its signal: fail-fast and
71
+ * abort resolve the run while stalled work is still pending (the top-level path has
72
+ * the same property through `finish`). `AbortSignal.any`'s reason is an AbortError.
73
+ */
74
+ function raceAbort(promise, signal) {
75
+ if (signal.aborted) return Promise.reject(signal.reason);
76
+ return new Promise((resolve, reject) => {
77
+ const onAbort = () => reject(signal.reason);
78
+ signal.addEventListener('abort', onAbort, { once: true });
79
+ Promise.resolve(promise).then(
80
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
81
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); },
82
+ );
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Build a scheduler over a resolved v2 template.
88
+ *
89
+ * @param {object} opts
90
+ * @param {object} opts.template v2 template `{ nodes, wires }`
91
+ * @param {(node:object) => ({inputs?:Array, outputs?:Array, verdict?:object}|undefined)} opts.portsFn
92
+ * @param {{loopWireIds:Set<string>, loopInputs:Set<string>, launchOrder:string[]}} [opts.loops]
93
+ * @param {(args:object) => Promise<object>} opts.execute
94
+ * @param {(name:'exec'|'token'|'gate', payload:object) => void} [opts.onEvent]
95
+ * @param {(snapshot:object) => void} [opts.onSnapshot]
96
+ * @param {(gate:{wireId,fromNode,toNode,askId}|null) => void} [opts.onGate]
97
+ * @param {(ask:object) => Promise<any>} [opts.onAsk]
98
+ * @param {number} [opts.maxParallel]
99
+ * @param {(line:string, attrs?:object) => void} [opts.log]
100
+ */
101
+ export function createScheduler(opts) {
102
+ const {
103
+ template,
104
+ portsFn,
105
+ execute,
106
+ loops = classifyLoops(template, portsFn),
107
+ onEvent = () => {},
108
+ onSnapshot = () => {},
109
+ onGate = () => {},
110
+ onAsk = defaultAsk,
111
+ maxParallel = defaultMaxParallel(),
112
+ log = () => {},
113
+ } = opts || {};
114
+
115
+ const nodes = Array.isArray(template?.nodes) ? template.nodes : [];
116
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
117
+ const wires = (Array.isArray(template?.wires) ? template.wires : [])
118
+ .filter((w) => nodeById.has(w?.from?.node) && nodeById.has(w?.to?.node));
119
+ const wireById = new Map(wires.map((w) => [w.id, w]));
120
+ const loopWireIds = new Set(loops?.loopWireIds || []);
121
+ const loopInputs = new Set(loops?.loopInputs || []);
122
+ const order = Array.isArray(loops?.launchOrder) && loops.launchOrder.length
123
+ ? loops.launchOrder.filter((id) => nodeById.has(id))
124
+ : nodes.map((n) => n.id);
125
+
126
+ // Static wiring indexes. `wiredIn` is keyed by BARE port id per node; `outWires`
127
+ // fans a fired output out to its wires.
128
+ const wiredIn = new Map(nodes.map((n) => [n.id, new Map()]));
129
+ const outWires = new Map();
130
+ for (const w of wires) {
131
+ wiredIn.get(w.to.node).set(w.to.port, w.id);
132
+ const key = `${w.from.node}.${w.from.port}`;
133
+ if (!outWires.has(key)) outWires.set(key, []);
134
+ outWires.get(key).push(w);
135
+ }
136
+
137
+ // --- run state -----------------------------------------------------------
138
+ let seq = 0;
139
+ const tokens = new Map(); // '<node>.<inputPort>' -> delivered token
140
+ const outputs = new Map(); // '<node>.<outputPort>' -> latched token
141
+ const consumed = new Map(); // nodeId -> Map(port -> seq), recorded at bind
142
+ const ordinals = new Map(); // nodeId -> executions started
143
+ const wireState = new Map(); // loop wireId -> { deliveries, allowance }
144
+ const deadEnds = new Set(); // '<nodeId>.<portId>' outputs that fired with no wire
145
+ const execs = new Map(); // executionId -> ledger entry
146
+ const held = new Map(); // wireId -> { wireId, nodeId, executionId, token, issues, askId }
147
+ const outstanding = new Set(); // wireIds with an un-withdrawn ask in flight
148
+ const running = new Map(); // nodeId -> executionId
149
+ const completions = [];
150
+ const warnings = [];
151
+ const controller = new AbortController();
152
+ let activeAgents = 0;
153
+ let ended = null;
154
+ let gate = null; // the CURRENT gate for state.gate (P4 stamps it)
155
+ let failure = null;
156
+ let pauseRequested = false;
157
+ let abortRequested = false;
158
+ let settled = false;
159
+
160
+ for (const id of loopWireIds) {
161
+ const raw = Number(wireById.get(id)?.config?.maxCycles ?? DEFAULT_MAX_CYCLES);
162
+ const maxCycles = Number.isFinite(raw) && raw >= 1 ? raw : DEFAULT_MAX_CYCLES;
163
+ wireState.set(id, { deliveries: 0, allowance: maxCycles - 1 }); // A1: allowance = maxCycles − 1
164
+ }
165
+
166
+ // --- wake plumbing -------------------------------------------------------
167
+ let signalled = false;
168
+ let waiter = null;
169
+ function wake() {
170
+ signalled = true;
171
+ if (waiter) { const w = waiter; waiter = null; w(); }
172
+ }
173
+ async function waitForChange() {
174
+ if (signalled) { signalled = false; return; }
175
+ await new Promise((res) => { waiter = res; });
176
+ signalled = false;
177
+ }
178
+
179
+ // --- the agent semaphore -------------------------------------------------
180
+ // A counting semaphore with a FIFO waiter queue. The drain walk POLLS it before
181
+ // launching a node; composite slices AWAIT it (they are launched from inside an
182
+ // already-running execution). The composite SHELL holds no slot, which is what
183
+ // keeps a fan-out from deadlocking behind itself at maxParallel 1.
184
+ const slotQueue = [];
185
+ function takeSlot() {
186
+ if (activeAgents < maxParallel) { activeAgents += 1; return Promise.resolve(); }
187
+ return new Promise((resolve) => { slotQueue.push(resolve); });
188
+ }
189
+ function freeSlot() {
190
+ const next = slotQueue.shift();
191
+ if (next) { next(); return; } // handed straight over: the count is unchanged
192
+ activeAgents -= 1;
193
+ wake(); // a freed slot may unblock a queued launch
194
+ }
195
+
196
+ // --- small helpers -------------------------------------------------------
197
+ /** This scheduler's 1-arg ports lookup (the shared `portsOf(portsFn, node)` is 2-arg). */
198
+ const portsOfNode = (node) => (typeof portsFn === 'function' ? portsFn(node) : null) || {};
199
+ const isFlow = (node) => FLOW_KINDS.includes(node.kind); // FLOW_KINDS is a frozen ARRAY (P1)
200
+ const spentOf = (nodeId) => consumed.get(nodeId) || new Map();
201
+
202
+ /** A port is a loop input when the classification says so OR its meta declares it.
203
+ * Both halves are load-bearing: `or.out -> agent.fix` is an ALWAYS-sourced wire
204
+ * (never a classified loop wire) into a `loop:true` port, and only the meta half
205
+ * excuses it from the first-run barrier. */
206
+ const isLoopPort = (nodeId, port) => Boolean(port?.loop) || loopInputs.has(`${nodeId}.${port?.id}`);
207
+
208
+ function makeToken({ type, path = null, value = null, meta = null, sourceExecutionId = null, forced = false }) {
209
+ seq += 1;
210
+ return { seq, type, path, value, meta, firedAt: Date.now(), sourceExecutionId, forced };
211
+ }
212
+
213
+ /** The payload half of a token, materialized only where it exists. */
214
+ function payloadOf(token) {
215
+ const out = { seq: token.seq, type: token.type };
216
+ if (token.path != null) out.path = token.path;
217
+ if (token.value != null) out.value = token.value;
218
+ if (token.meta != null) out.meta = token.meta;
219
+ if (token.forced) out.forced = true;
220
+ return out;
221
+ }
222
+
223
+ function emitExec(node, entry, status, extra) {
224
+ onEvent('exec', {
225
+ nodeId: node.id,
226
+ executionId: entry.executionId,
227
+ kind: entry.kind,
228
+ ordinal: entry.ordinal,
229
+ status,
230
+ agentKey: node.kind === 'agent' ? (node.key ?? null) : null, // flow rows carry none
231
+ trigger: entry.trigger,
232
+ // Composite sub-executions carry their slice identity; the UI collapses them
233
+ // under the node and labels them by title (A9: taskIndex/taskTotal ride along).
234
+ ...(entry.kind === 'task'
235
+ ? { phase: entry.phase, taskId: entry.taskId, title: entry.title, parentExecutionId: entry.parentExecutionId,
236
+ taskIndex: entry.taskIndex, taskTotal: entry.taskTotal }
237
+ : null),
238
+ ...(extra || null),
239
+ });
240
+ }
241
+
242
+ function emitToken(node, port, token) {
243
+ onEvent('token', {
244
+ seq: token.seq,
245
+ from: { node: node.id, port: port.id },
246
+ to: (outWires.get(`${node.id}.${port.id}`) || [])
247
+ .map((w) => ({ node: w.to.node, port: w.to.port, wireId: w.id })),
248
+ type: token.type,
249
+ path: token.path,
250
+ forced: token.forced,
251
+ firedAt: token.firedAt,
252
+ sourceExecutionId: token.sourceExecutionId,
253
+ });
254
+ }
255
+
256
+ // --- binding -------------------------------------------------------------
257
+
258
+ /**
259
+ * Latch this execution's inputs. Every input holding a token is bound (a
260
+ * re-execution binds latched values for its non-triggering inputs) and spent in
261
+ * `consumed`; the OR card binds ONLY the freshest fresh input, so the older fresh
262
+ * tokens are spent at that same bind without being bound.
263
+ */
264
+ function bindFor(node) {
265
+ const inputs = portsOfNode(node).inputs || [];
266
+ const spent = spentOf(node.id);
267
+ const present = [];
268
+ for (const inp of inputs) {
269
+ const token = tokens.get(`${node.id}.${inp.id}`);
270
+ if (!token) continue;
271
+ const prior = spent.get(inp.id);
272
+ present.push({ port: inp.id, token, fresh: prior === undefined || token.seq > prior });
273
+ }
274
+
275
+ let bound = present;
276
+ if (node.kind === 'or') {
277
+ const freshest = present
278
+ .filter((p) => p.fresh)
279
+ .reduce((best, p) => (best && best.token.seq >= p.token.seq ? best : p), null);
280
+ bound = freshest ? [freshest] : [];
281
+ }
282
+
283
+ const bindings = {};
284
+ for (const p of bound) {
285
+ if (p.port === AWAIT_ID) continue; // consumed for the barrier, payload discarded
286
+ bindings[p.port] = payloadOf(p.token);
287
+ }
288
+ const fresh = present.filter((p) => p.fresh);
289
+ return {
290
+ bindings,
291
+ present,
292
+ trigger: {
293
+ wireIds: fresh.map((p) => wiredIn.get(node.id)?.get(p.port)).filter(Boolean),
294
+ // A3: only a FRESH port selects the mode. `await` never does — it is a barrier,
295
+ // not a payload, so it is never listed (first executions and re-fires alike).
296
+ freshPorts: fresh.filter((p) => p.port !== AWAIT_ID).map((p) => p.port),
297
+ },
298
+ };
299
+ }
300
+
301
+ function commitBind(node, present) {
302
+ let spent = consumed.get(node.id);
303
+ if (!spent) { spent = new Map(); consumed.set(node.id, spent); }
304
+ for (const p of present) spent.set(p.port, p.token.seq);
305
+ }
306
+
307
+ // --- launching -----------------------------------------------------------
308
+
309
+ function startExecution(node) {
310
+ const ordinal = (ordinals.get(node.id) || 0) + 1;
311
+ const executionId = `x:${node.id}:${ordinal}`;
312
+ const b = bindFor(node); // reads `consumed` — bind BEFORE the bump
313
+ ordinals.set(node.id, ordinal);
314
+ commitBind(node, b.present);
315
+ const entry = {
316
+ executionId,
317
+ nodeId: node.id,
318
+ kind: 'cycle',
319
+ ordinal,
320
+ status: 'start',
321
+ sessionId: null,
322
+ bindings: b.bindings,
323
+ trigger: b.trigger,
324
+ };
325
+ const expandsPort = expandsTrigger(node, b);
326
+ if (expandsPort) entry.expandsPort = expandsPort;
327
+ execs.set(executionId, entry);
328
+ running.set(node.id, executionId);
329
+ emitExec(node, entry, 'start');
330
+ return { node, entry, args: argsFor(node, entry), composite: !!expandsPort };
331
+ }
332
+
333
+ function argsFor(node, entry) {
334
+ return {
335
+ node,
336
+ executionId: entry.executionId,
337
+ ordinal: entry.ordinal,
338
+ bindings: entry.bindings,
339
+ trigger: entry.trigger,
340
+ signal: controller.signal,
341
+ };
342
+ }
343
+
344
+ /** Flow cards run inline at their slot so their publishes reach later slots. */
345
+ async function fireFlow(node) {
346
+ const h = startExecution(node);
347
+ let res = null;
348
+ let err = null;
349
+ try { res = await execute(h.args); } catch (e) { err = e; }
350
+ settle(h, res, err);
351
+ }
352
+
353
+ /** Agent nodes take a semaphore slot; `execute` is called AT the slot. */
354
+ function fireAgent(node) {
355
+ const h = startExecution(node);
356
+ if (!h.composite) activeAgents += 1;
357
+ let p;
358
+ try { p = invoke(h); } catch (err) { p = Promise.reject(err); }
359
+ Promise.resolve(p).then(
360
+ (res) => { completions.push({ h, res, err: null }); wake(); },
361
+ (err) => { completions.push({ h, res: null, err }); wake(); },
362
+ );
363
+ }
364
+
365
+ /** Run one started execution: the composite driver, or the plain injected call. */
366
+ function invoke(h) {
367
+ return h.composite ? runComposite(h) : execute(h.args);
368
+ }
369
+
370
+ /**
371
+ * The FRESH `expands` input that makes this firing COMPOSITE, or null.
372
+ *
373
+ * A3, parity-mandatory: a fresh LOOP input wins outright — a fix-cycle re-fire runs
374
+ * ONE ordinary execution on the combined diff, which is v1's `!bus.review` arm of
375
+ * the same guard. A latched expands token never fans out again either, because only
376
+ * FRESH ports are considered.
377
+ */
378
+ function expandsTrigger(node, b) {
379
+ if (isFlow(node)) return null;
380
+ const inputs = portsOfNode(node).inputs || [];
381
+ const fresh = new Set(b.trigger.freshPorts || []);
382
+ if (inputs.some((inp) => isLoopPort(node.id, inp) && fresh.has(inp.id))) return null;
383
+ const port = inputs.find((inp) => inp?.expands && fresh.has(inp.id) && b.bindings[inp.id]);
384
+ return port ? port.id : null;
385
+ }
386
+
387
+ /**
388
+ * Drive ONE composite execution. Phases run in order, each phase's tasks in
389
+ * parallel under the semaphore, and the single value returned here is what the node
390
+ * PUBLISHES — so its outputs fire exactly once, after the last phase, never once
391
+ * per task. Pause/abort is checked at every phase boundary.
392
+ */
393
+ async function runComposite(h) {
394
+ const portId = h.entry.expandsPort;
395
+ const expanded = await execute({ ...h.args, composite: 'expand', expandsPort: portId });
396
+ const phases = Array.isArray(expanded?.phases) ? expanded.phases : [];
397
+ if (!phases.length) return runUnexpanded(h, portId);
398
+
399
+ // A halted run returns WITHOUT `finish`: nothing is staged and no phase is falsely
400
+ // marked done. A PAUSE answers `{ paused: true }` so the shell row stays
401
+ // non-terminal and the resume re-runs the whole fan-out; any other halt (End,
402
+ // abort, failure) answers `{ skipped: true }` — the base spec's Completion
403
+ // paragraph puts "anything cut off by the End drain" in `skipped`. The old
404
+ // `{ outputs: {} }` was read as a SUCCESSFUL completion: the ledger row said
405
+ // `done` although finish() never ran, and an empty (path:null) token latched
406
+ // into the snapshot and animated in the monitor.
407
+ const bail = (paused) => (paused || pauseRequested ? { paused: true } : { skipped: true });
408
+ for (const ph of phases) {
409
+ if (halted()) return bail(false);
410
+ const { paused } = await runPhase(h, portId, ph);
411
+ if (paused || halted()) return bail(paused);
412
+ }
413
+ return await execute({ ...h.args, composite: 'finish', expandsPort: portId, phases });
414
+ }
415
+
416
+ /**
417
+ * Nothing to fan out. Strip the expands binding — so the consumer neither sees the
418
+ * manifest as an input nor renders its slice directive (A3) — and run the ONE
419
+ * ordinary execution the firing would have been. The entry is mutated in place
420
+ * because it is the ledger row a resume would re-invoke from.
421
+ */
422
+ async function runUnexpanded(h, portId) {
423
+ const { node, entry } = h;
424
+ const wireId = wiredIn.get(node.id)?.get(portId);
425
+ delete entry.bindings[portId];
426
+ entry.trigger = {
427
+ wireIds: (entry.trigger.wireIds || []).filter((w) => w !== wireId),
428
+ freshPorts: (entry.trigger.freshPorts || []).filter((p) => p !== portId),
429
+ };
430
+ delete entry.expandsPort;
431
+ h.composite = false; // … so settle() frees the slot taken here
432
+ await takeSlot();
433
+ h.args = argsFor(node, entry);
434
+ return await execute(h.args);
435
+ }
436
+
437
+ /**
438
+ * One phase: every task launched together, each awaiting its own semaphore slot.
439
+ * The FIRST genuine (non-abort) failure aborts its siblings immediately through the
440
+ * phase-local controller and fails the whole composite — v1's abort-on-first-
441
+ * failure, kept. Returns `{ paused }` — true when any slice answered `{ paused: true }`.
442
+ */
443
+ async function runPhase(h, portId, ph) {
444
+ const tasks = Array.isArray(ph.tasks) ? ph.tasks : [];
445
+ const phaseAbort = new AbortController();
446
+ let firstError = null;
447
+ await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'running' });
448
+
449
+ const results = await Promise.allSettled(tasks.map((task, index) =>
450
+ runSlice(h, portId, ph, task, index, phaseAbort).catch((err) => {
451
+ // The slice already aborted its phase-mates (see runSlice); record the FIRST
452
+ // genuine failure as the composite's error.
453
+ if (!firstError && !isAbortError(err)) firstError = { task, err };
454
+ throw err;
455
+ })));
456
+
457
+ if (firstError) {
458
+ await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'error' });
459
+ const label = firstError.task.title || firstError.task.id;
460
+ throw new Error(
461
+ `composite execution failed in phase ${ph.ordinal}: task "${label}": ` +
462
+ `${firstError.err?.message || firstError.err}`,
463
+ );
464
+ }
465
+ const paused = results.some((r) => r.status === 'fulfilled' && r.value?.paused === true);
466
+ // A halted or paused run leaves the phase RUNNING: the resume re-runs the whole
467
+ // composite, and a phase that never finished must not read as done.
468
+ if (paused || halted()) return { paused };
469
+ await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'done' });
470
+ return { paused: false };
471
+ }
472
+
473
+ /**
474
+ * One task sub-execution: the consumer node with its expands input rebound to this
475
+ * task's own markdown file, still FRESH so the slice directive renders, and its
476
+ * phase-mates listed in `slice.siblings` (the shared-working-tree block). Recorded
477
+ * `kind:'task'` under the SAME node — it publishes nothing; `finish` does that.
478
+ * The adapter's `{ paused: true }` / `{ error }` answers are honored exactly as
479
+ * `settle` honors them for a top-level execution.
480
+ */
481
+ async function runSlice(h, portId, ph, task, index, phaseAbort) {
482
+ const { node, entry } = h;
483
+ const signal = AbortSignal.any([controller.signal, phaseAbort.signal]);
484
+ await takeSlot();
485
+ // A slot handed over AFTER the phase (or the run) aborted: never launch. The slice
486
+ // gets no ledger row (it never started) and rejects with the abort reason, so it
487
+ // is not counted as the phase's failure.
488
+ if (signal.aborted) { freeSlot(); throw signal.reason; }
489
+ const sub = {
490
+ executionId: sliceExecutionId(entry.executionId, task.id),
491
+ nodeId: node.id,
492
+ kind: 'task',
493
+ ordinal: entry.ordinal,
494
+ status: 'start',
495
+ sessionId: null,
496
+ phase: ph.ordinal,
497
+ taskId: task.id,
498
+ title: task.title || task.id,
499
+ parentExecutionId: entry.executionId,
500
+ taskIndex: index + 1, // 1-based within its phase (the CLI's "task 3/7")
501
+ taskTotal: (Array.isArray(ph.tasks) ? ph.tasks : []).length,
502
+ bindings: {
503
+ ...entry.bindings,
504
+ [portId]: { seq: entry.bindings[portId]?.seq, type: 'md', path: task.path ?? null },
505
+ },
506
+ trigger: entry.trigger,
507
+ };
508
+ execs.set(sub.executionId, sub);
509
+ emitExec(node, sub, 'start');
510
+ const args = {
511
+ ...argsFor(node, sub),
512
+ node: { ...node },
513
+ signal,
514
+ kind: 'task',
515
+ parentExecutionId: sub.parentExecutionId, // the adapter's ledger row + exec_meta read these three
516
+ taskIndex: sub.taskIndex,
517
+ taskTotal: sub.taskTotal,
518
+ slice: {
519
+ id: task.id,
520
+ title: task.title ?? null,
521
+ phase: ph.ordinal,
522
+ path: task.path ?? null,
523
+ index,
524
+ siblings: (Array.isArray(ph.tasks) ? ph.tasks : [])
525
+ .filter((t) => t.id !== task.id)
526
+ .map((t) => ({ id: t.id, title: t.title ?? null, file: t.file ?? null })),
527
+ },
528
+ };
529
+ try {
530
+ const res = await raceAbort(execute(args), signal);
531
+ if (res?.error) throw (res.error instanceof Error ? res.error : new Error(String(res.error)));
532
+ sub.status = res?.paused === true ? 'paused' : 'done';
533
+ if (res?.sessionId) sub.sessionId = res.sessionId;
534
+ emitExec(node, sub, sub.status);
535
+ return res;
536
+ } catch (err) {
537
+ sub.status = 'error';
538
+ sub.error = String(err?.message || err);
539
+ emitExec(node, sub, 'error', { error: sub.error });
540
+ // Fail-fast: the FIRST genuine failure aborts its phase-mates HERE — before the
541
+ // slot is handed on in `finally` — so a queued sibling wakes up already aborted
542
+ // and never launches.
543
+ if (!isAbortError(err)) phaseAbort.abort();
544
+ throw err;
545
+ } finally {
546
+ freeSlot();
547
+ }
548
+ }
549
+
550
+ function settle(h, res, err) {
551
+ running.delete(h.node.id);
552
+ if (!isFlow(h.node) && !h.composite) freeSlot();
553
+ if (err || res?.error) failExecution(h, err || res.error);
554
+ else if (res?.paused === true) pausedExecution(h);
555
+ else if (res?.skipped === true) skippedExecution(h);
556
+ else completeExecution(h, res || {});
557
+ }
558
+
559
+ /**
560
+ * The execution was CUT SHORT by a terminal run condition (the End drain, an abort,
561
+ * a sibling's failure) rather than completing. TERMINAL, so a resume never re-invokes
562
+ * it, but it publishes NOTHING — no token event, no latched payload, and no `done`
563
+ * in the ledger for work that did not happen.
564
+ */
565
+ function skippedExecution(h) {
566
+ const { node, entry } = h;
567
+ entry.status = 'skipped';
568
+ emitExec(node, entry, 'skipped');
569
+ snap();
570
+ }
571
+
572
+ function completeExecution(h, res) {
573
+ const { node, entry } = h;
574
+ entry.status = 'done';
575
+ // An execution may report NON-FATAL problems (a verifier that never wrote its
576
+ // verdict, MAJ-10). The scheduler owns `warnings` and the injected `log`, so
577
+ // they ride the execution result rather than a second channel.
578
+ for (const w of Array.isArray(res?.warnings) ? res.warnings : []) {
579
+ const text = String(w || '');
580
+ if (!text) continue;
581
+ warnings.push(text);
582
+ log(text);
583
+ }
584
+ if (res?.sessionId) entry.sessionId = res.sessionId;
585
+ emitExec(node, entry, 'done', {
586
+ ...(node.kind === 'end' ? { result: boundResult(entry) } : null),
587
+ ...(res?.verdict ? { verdict: { hasBlocking: hasBlocking(res.verdict), ...(res.verdict.missing ? { missing: true } : {}) } } : null),
588
+ });
589
+ publish(node, entry, res);
590
+ snap();
591
+ }
592
+
593
+ function failExecution(h, err) {
594
+ const { node, entry } = h;
595
+ entry.status = 'error';
596
+ entry.error = String(err?.message || err);
597
+ emitExec(node, entry, 'error', { error: entry.error });
598
+ failure = err;
599
+ controller.abort(); // fail-fast aborts everything in flight
600
+ snap();
601
+ }
602
+
603
+ /**
604
+ * A pause cancelled this execution (or, for a composite shell, one of its slices).
605
+ * The row stays NON-TERMINAL and nothing is published, so `reattach` re-invokes it
606
+ * with the recorded args on resume.
607
+ */
608
+ function pausedExecution(h) {
609
+ const { node, entry } = h;
610
+ entry.status = 'paused';
611
+ emitExec(node, entry, 'paused');
612
+ // The EXECUTION paused the run (the adapter's ask-then-resume path, or its pause
613
+ // abort): treat it exactly like pause() — nothing else launches and run() resolves
614
+ // 'paused' once the in-flight work drains — instead of quiescing to a false 'done'.
615
+ pauseRequested = true;
616
+ snap();
617
+ }
618
+
619
+ // --- publishing / routing ------------------------------------------------
620
+
621
+ /** End's result is derived from the token the SCHEDULER bound, never from the
622
+ * execution's (informational) return value. Shape per A7: {type, path?, value?}. */
623
+ function boundResult(entry) {
624
+ const bound = Object.values(entry.bindings)[0] || {};
625
+ const result = { type: bound.type ?? 'void' };
626
+ if (bound.path != null) result.path = bound.path;
627
+ if (bound.value != null) result.value = bound.value;
628
+ return result;
629
+ }
630
+
631
+ function publish(node, entry, res) {
632
+ if (node.kind === 'end') {
633
+ ended = {
634
+ nodeId: node.id,
635
+ executionId: entry.executionId,
636
+ seq: Object.values(entry.bindings)[0]?.seq ?? null,
637
+ result: boundResult(entry),
638
+ };
639
+ withdrawGates(); // no run() may block on a pending ask now
640
+ return; // zero outputs — the publish step fires no token
641
+ }
642
+ const verdict = res?.verdict ?? null;
643
+ for (const port of firedOutputs(portsOfNode(node).outputs || [], verdict)) {
644
+ const token = makeToken({
645
+ type: outTypeOf(node, port),
646
+ ...payloadFor(node, port, entry, res),
647
+ sourceExecutionId: entry.executionId,
648
+ });
649
+ emitToken(node, port, token);
650
+ outputs.set(`${node.id}.${port.id}`, token);
651
+ route(node, port, token, verdict, entry.executionId);
652
+ }
653
+ }
654
+
655
+ const outTypeOf = (node, port) => (node.kind === 'or'
656
+ ? (resolveOrOutType(template, portsFn, node.id) ?? port.type)
657
+ : port.type);
658
+
659
+ function payloadFor(node, port, entry, res) {
660
+ // The OR valve re-emits the token IT bound — payload AND provenance (an A4
661
+ // forced token keeps its flag and its open issues through the valve); the AND
662
+ // card is a pure synchronizer and emits void.
663
+ if (node.kind === 'or') {
664
+ const bound = Object.values(entry.bindings)[0] || {};
665
+ return { path: bound.path ?? null, value: bound.value ?? null, meta: bound.meta ?? null, forced: !!bound.forced };
666
+ }
667
+ if (node.kind === 'and') return { path: null, value: null };
668
+ const given = res?.outputs?.[port.id];
669
+ if (given) return { path: given.path ?? null, value: given.value ?? null };
670
+ return { path: null, value: null };
671
+ }
672
+
673
+ /**
674
+ * Deliver a token along every wire out of a fired port. Loop-wire deliveries are
675
+ * counted and gated HERE, per wire — before, and independently of, any downstream
676
+ * bind. During the End drain nothing is routed at all: the token is recorded
677
+ * (latched + evented) and accounting/gates are skipped.
678
+ */
679
+ function route(node, port, token, verdict, executionId) {
680
+ if (ended) return;
681
+ const outs = outWires.get(`${node.id}.${port.id}`) || [];
682
+ // An output that fires with no wire is a DEAD END: its token has nowhere to go.
683
+ // Legal (a conditional branch may be deliberately unwired — wf_no-clarify's
684
+ // n_webui.review is v1 parity), but it is the one structural fact that explains
685
+ // a run finishing at quiescence, so the warning names it.
686
+ if (!outs.length) deadEnds.add(`${node.id}.${port.id}`);
687
+ for (const w of outs) {
688
+ const st = wireState.get(w.id);
689
+ if (st) {
690
+ if (st.deliveries >= st.allowance) { holdAt(w, token, verdict, node, executionId); continue; }
691
+ st.deliveries += 1;
692
+ }
693
+ tokens.set(`${w.to.node}.${w.to.port}`, token);
694
+ }
695
+ }
696
+
697
+ // --- gates ---------------------------------------------------------------
698
+
699
+ /** The current gate descriptor for `state.gate` — the FIRST hold, or null. */
700
+ function syncGate() {
701
+ const first = held.values().next().value || null;
702
+ const w = first ? wireById.get(first.wireId) : null;
703
+ const next = first && w
704
+ ? { wireId: first.wireId, fromNode: w.from.node, toNode: w.to.node, askId: first.askId }
705
+ : null;
706
+ const changed = JSON.stringify(next ?? null) !== JSON.stringify(gate ?? null);
707
+ gate = next;
708
+ if (changed) onGate(gate ? { ...gate } : null);
709
+ }
710
+
711
+ function askGate(entry) {
712
+ Promise.resolve(onAsk({
713
+ id: entry.askId,
714
+ kind: 'gate',
715
+ wireId: entry.wireId,
716
+ nodeId: entry.nodeId,
717
+ executionId: entry.executionId,
718
+ issues: entry.issues,
719
+ // The CYCLE this hold stands in for, and WHICH hold it is. Both ride the
720
+ // payload because the id is opaque: a re-held wire suffixes `-h<holdNo>`,
721
+ // so anything parsing a trailing number off the id reads the hold ordinal
722
+ // as the cycle (src/cli/render.mjs formatGateHeader).
723
+ deliveryNo: entry.deliveryNo,
724
+ holdNo: entry.holdNo,
725
+ })).then(
726
+ (answer) => resolveGate(entry.wireId, answer),
727
+ () => resolveGate(entry.wireId, 'continue'),
728
+ );
729
+ }
730
+
731
+ /** Past the allowance: HOLD the token and ask the human. "Open issues" = the
732
+ * critical/major findings that caused the block; they ride the ask and, on
733
+ * continue, the forced token's meta. A wire that is ALREADY held keeps its first
734
+ * hold — a later over-budget token on the same wire is dropped, so one ask never
735
+ * answers for a token it was not raised about.
736
+ *
737
+ * The ask id is UNIQUE PER HOLD. resolveGate('continue') advances neither
738
+ * counter, so the next blocking token on the same spent wire holds again with the
739
+ * same deliveryNo; minting the same id twice let a retried/duplicated answer
740
+ * resolve a hold the user never saw (run-harness.answer matches by id), and made
741
+ * the two holds indistinguishable in the audit trail. `st.holds` is monotonic per
742
+ * wire and rides the snapshot with the rest of the wire state, so a resume keeps
743
+ * counting. The first hold keeps the original `gate-<wireId>-<deliveryNo>`. */
744
+ function holdAt(wire, token, verdict, node, executionId) {
745
+ if (held.has(wire.id)) return;
746
+ const st = wireState.get(wire.id);
747
+ const deliveryNo = st.deliveries + 1; // the delivery this hold stands in for
748
+ const holdNo = (st.holds = (st.holds || 0) + 1); // which hold on THIS wire (1-based)
749
+ const issues = blockingIssues(verdict);
750
+ const askId = holdNo > 1
751
+ ? `gate-${wire.id}-${deliveryNo}-h${holdNo}`
752
+ : `gate-${wire.id}-${deliveryNo}`;
753
+ const entry = { wireId: wire.id, nodeId: node.id, executionId, token, issues, askId, deliveryNo, holdNo };
754
+ held.set(wire.id, entry);
755
+ outstanding.add(wire.id);
756
+ onEvent('gate', {
757
+ wireId: wire.id, nodeId: node.id, executionId, issues, askId, deliveryNo, holdNo, status: 'held',
758
+ });
759
+ syncGate();
760
+ askGate(entry);
761
+ }
762
+
763
+ function resolveGate(wireId, answer) {
764
+ if (settled) return; // the run already resolved: the resume re-asks this hold (P3-36)
765
+ if (!outstanding.has(wireId)) return; // withdrawn by End (or already answered) — a no-op
766
+ outstanding.delete(wireId);
767
+ const entry = held.get(wireId);
768
+ held.delete(wireId);
769
+ syncGate();
770
+ if (!entry) return;
771
+ const decision = answer === 'another' ? 'another' : 'continue';
772
+ onEvent('gate', {
773
+ wireId, nodeId: entry.nodeId, executionId: entry.executionId,
774
+ issues: entry.issues, askId: entry.askId,
775
+ deliveryNo: entry.deliveryNo, holdNo: entry.holdNo, status: decision,
776
+ });
777
+ if (decision === 'another') {
778
+ const st = wireState.get(wireId);
779
+ st.allowance += 1;
780
+ st.deliveries += 1;
781
+ const w = wireById.get(wireId);
782
+ tokens.set(`${w.to.node}.${w.to.port}`, entry.token);
783
+ } else {
784
+ forceClean(entry);
785
+ }
786
+ snap();
787
+ wake();
788
+ }
789
+
790
+ /**
791
+ * A4: on "continue" the held blocking token is discarded and each of the SOURCE
792
+ * node's clean outputs force-fires — payload = the held token's path/value when
793
+ * the port types match, else the clean port's latched payload, else null;
794
+ * `forced` + the open issues in meta either way.
795
+ */
796
+ function forceClean(entry) {
797
+ const node = nodeById.get(entry.nodeId);
798
+ if (!node) return;
799
+ for (const port of (portsOfNode(node).outputs || []).filter((o) => o.when === 'clean')) {
800
+ const latched = outputs.get(`${node.id}.${port.id}`);
801
+ const payload = port.type === entry.token.type
802
+ ? { path: entry.token.path ?? null, value: entry.token.value ?? null }
803
+ : latched
804
+ ? { path: latched.path ?? null, value: latched.value ?? null }
805
+ : { path: null, value: null };
806
+ const token = makeToken({
807
+ type: port.type,
808
+ ...payload,
809
+ meta: { issues: entry.issues },
810
+ sourceExecutionId: entry.executionId,
811
+ forced: true,
812
+ });
813
+ emitToken(node, port, token);
814
+ outputs.set(`${node.id}.${port.id}`, token);
815
+ route(node, port, token, null, entry.executionId);
816
+ }
817
+ }
818
+
819
+ /** End reached: stop awaiting every outstanding ask and drop the held state. */
820
+ function withdrawGates() {
821
+ held.clear();
822
+ outstanding.clear();
823
+ syncGate();
824
+ }
825
+
826
+ // --- snapshot ------------------------------------------------------------
827
+
828
+ function snapshotObject() {
829
+ // `held` is a Map — N loop wires can block in ONE drain (two verifiers into an
830
+ // OR, both at allowance). Every hold is serialized; `gate`/`ask` keep their
831
+ // singular spec shape as the FIRST hold.
832
+ const gates = [...held.values()].map((g) => ({
833
+ wireId: g.wireId, nodeId: g.nodeId, executionId: g.executionId,
834
+ token: g.token, issues: g.issues, askId: g.askId,
835
+ deliveryNo: g.deliveryNo, holdNo: g.holdNo,
836
+ }));
837
+ const asks = gates.map((g) => ({
838
+ id: g.askId, kind: 'gate', wireId: g.wireId, nodeId: g.nodeId,
839
+ executionId: g.executionId, issues: g.issues,
840
+ deliveryNo: g.deliveryNo, holdNo: g.holdNo,
841
+ }));
842
+ return {
843
+ version: 2,
844
+ seq,
845
+ graph: template,
846
+ tokens: Object.fromEntries(tokens),
847
+ outputs: Object.fromEntries(outputs),
848
+ consumed: Object.fromEntries([...consumed].map(([id, m]) => [id, Object.fromEntries(m)])),
849
+ ordinals: Object.fromEntries(ordinals),
850
+ wires: Object.fromEntries([...wireState].map(([id, st]) => [id, { ...st }])),
851
+ // Scheduler-local like `wires`: a run that paused AFTER an output dead-ended
852
+ // and quiesces only after the resume must still be able to say WHY.
853
+ deadEnds: [...deadEnds].sort(),
854
+ ended: ended ? { ...ended, result: { ...ended.result } } : null,
855
+ // The FULL ledger entry is serialized (bindings + trigger included): reattach
856
+ // re-invokes `execute` with the RECORDED args, and recomputing them from
857
+ // `consumed` + `tokens` would silently change what a resumed execution works on.
858
+ execs: [...execs.values()].map((e) => ({ ...e })),
859
+ gates,
860
+ asks,
861
+ gate: gates[0] || null,
862
+ ask: asks[0] || null,
863
+ };
864
+ }
865
+
866
+ const snap = () => onSnapshot(snapshotObject());
867
+
868
+ function restore(s) {
869
+ if (!s) return;
870
+ seq = s.seq ?? 0;
871
+ for (const [k, v] of Object.entries(s.tokens || {})) tokens.set(k, v);
872
+ for (const [k, v] of Object.entries(s.outputs || {})) outputs.set(k, v);
873
+ for (const [id, m] of Object.entries(s.consumed || {})) consumed.set(id, new Map(Object.entries(m)));
874
+ for (const [id, n] of Object.entries(s.ordinals || {})) ordinals.set(id, n);
875
+ for (const [id, st] of Object.entries(s.wires || {})) wireState.set(id, { ...st });
876
+ for (const id of s.deadEnds || []) deadEnds.add(id);
877
+ for (const e of s.execs || []) execs.set(e.executionId, { ...e });
878
+ ended = s.ended ? { ...s.ended, result: { ...s.ended.result } } : null;
879
+ // Every hold comes back (reattach re-asks all of them). A pre-plural resume point
880
+ // carries only the singular `gate`; read it as a one-element list.
881
+ const gates = Array.isArray(s.gates) ? s.gates : (s.gate ? [s.gate] : []);
882
+ if (!ended) for (const g of gates) held.set(g.wireId, { ...g });
883
+ }
884
+
885
+ /**
886
+ * Restore a snapshot and re-invoke `execute` once per NON-TERMINAL execution with
887
+ * exactly the recorded arguments — the injected execute decides re-attach vs
888
+ * re-run. Call BEFORE `run()`.
889
+ *
890
+ * A composite's slices are re-invoked BY their shell, not from here: the shell
891
+ * re-runs the whole fan-out (v1 resumed the decomposed stage whole) and re-mints
892
+ * the same ids, so those stale rows are overwritten in place.
893
+ */
894
+ function reattach(snapshot) {
895
+ restore(snapshot);
896
+ for (const entry of [...execs.values()]) {
897
+ if (TERMINAL.has(entry.status)) continue;
898
+ if (entry.kind === 'task') continue;
899
+ const node = nodeById.get(entry.nodeId);
900
+ if (!node) continue;
901
+ running.set(node.id, entry.executionId);
902
+ const h = { node, entry, args: argsFor(node, entry), composite: !!entry.expandsPort };
903
+ if (!isFlow(node) && !h.composite) activeAgents += 1;
904
+ let p;
905
+ try { p = invoke(h); } catch (err) { p = Promise.reject(err); }
906
+ Promise.resolve(p).then(
907
+ (res) => { completions.push({ h, res, err: null }); wake(); },
908
+ (err) => { completions.push({ h, res: null, err }); wake(); },
909
+ );
910
+ }
911
+ // A gate restored without an End re-raises its ask — otherwise the held token has
912
+ // nobody to answer for it and the run would deadlock.
913
+ for (const entry of [...held.values()]) {
914
+ if (outstanding.has(entry.wireId)) continue;
915
+ outstanding.add(entry.wireId);
916
+ askGate(entry);
917
+ }
918
+ syncGate();
919
+ }
920
+
921
+ // --- readiness -----------------------------------------------------------
922
+
923
+ /**
924
+ * Amendment f, §3 "Firing rule". Flow kinds first (Task fires once; End/AND/
925
+ * Combine all-fresh every execution; OR any-fresh), then the agent rules: the
926
+ * first-run barrier over wired non-loop inputs (the synthesized `await` port
927
+ * included), then any-fresh (default) or awaitAll.
928
+ */
929
+ function isReady(node) {
930
+ const inputs = portsOfNode(node).inputs || [];
931
+ const wired = wiredIn.get(node.id) || new Map();
932
+ const spent = spentOf(node.id);
933
+ const everRan = (ordinals.get(node.id) || 0) > 0;
934
+ const awaitAll = node.config?.awaitAll === true;
935
+ const isFresh = (port) => {
936
+ const token = tokens.get(`${node.id}.${port}`);
937
+ if (!token) return false;
938
+ const prior = spent.get(port);
939
+ return prior === undefined || token.seq > prior;
940
+ };
941
+
942
+ if (isFlow(node)) {
943
+ switch (node.kind) {
944
+ case 'task':
945
+ return !everRan; // zero inputs; fires once at t0
946
+ case 'end':
947
+ case 'and':
948
+ case 'combine':
949
+ return inputs.length > 0 && inputs.every((inp) => isFresh(inp.id));
950
+ case 'or':
951
+ return inputs.some((inp) => isFresh(inp.id));
952
+ default:
953
+ return false; // unknown kind (V3)
954
+ }
955
+ }
956
+
957
+ if (!everRan) {
958
+ for (const inp of inputs) {
959
+ if (!wired.has(inp.id)) {
960
+ // V9 blocks this at save; stay defensively un-ready rather than firing a
961
+ // node whose required payload can never arrive. Loop inputs are exempt.
962
+ if (inp.required && !isLoopPort(node.id, inp)) return false;
963
+ continue;
964
+ }
965
+ if (isLoopPort(node.id, inp)) continue; // excused from the barrier
966
+ if (!tokens.get(`${node.id}.${inp.id}`)) return false;
967
+ }
968
+ return true;
969
+ }
970
+
971
+ if (!awaitAll) return inputs.some((inp) => isFresh(inp.id));
972
+
973
+ // awaitAll: a fresh loop token alone always re-fires (the loop path is the point).
974
+ if (inputs.some((inp) => isLoopPort(node.id, inp) && isFresh(inp.id))) return true;
975
+ let barrier = false;
976
+ for (const inp of inputs) {
977
+ if (!wired.has(inp.id) || isLoopPort(node.id, inp)) continue;
978
+ barrier = true;
979
+ if (!isFresh(inp.id)) return false;
980
+ }
981
+ return barrier;
982
+ }
983
+
984
+ const halted = () => Boolean(ended) || Boolean(failure) || pauseRequested || abortRequested;
985
+
986
+ async function drainPasses() {
987
+ for (;;) {
988
+ let fired = false;
989
+ for (const nodeId of order) {
990
+ if (halted()) return; // pause/abort/End checked at every launch decision
991
+ const node = nodeById.get(nodeId);
992
+ if (!node || running.has(nodeId) || !isReady(node)) continue;
993
+ if (isFlow(node)) { await fireFlow(node); fired = true; continue; }
994
+ if (activeAgents >= maxParallel) continue; // capped: retried once a slot frees
995
+ fireAgent(node);
996
+ fired = true;
997
+ }
998
+ if (!fired) return;
999
+ }
1000
+ }
1001
+
1002
+ function finish(result) {
1003
+ settled = true;
1004
+ if (result === 'error') controller.abort();
1005
+ if (result === 'done' && !ended) {
1006
+ warnings.push(QUIESCENCE_WARNING);
1007
+ log(QUIESCENCE_WARNING);
1008
+ if (deadEnds.size) {
1009
+ const detail = quiescenceDeadEnd([...deadEnds].sort());
1010
+ warnings.push(detail);
1011
+ log(detail);
1012
+ }
1013
+ }
1014
+ snap(); // one final snapshot at run resolution
1015
+ return result;
1016
+ }
1017
+
1018
+ async function run() {
1019
+ for (;;) {
1020
+ while (completions.length) {
1021
+ const c = completions.shift();
1022
+ settle(c.h, c.res, c.err);
1023
+ }
1024
+ if (failure) return finish('error');
1025
+ if (abortRequested) return finish('error');
1026
+ if (!halted()) await drainPasses();
1027
+ if (failure) return finish('error');
1028
+ if (abortRequested) return finish('error');
1029
+ // Anything that landed while the pass ran — a completion, or a gate answer
1030
+ // that delivered its held token — gets its own pass before quiescence.
1031
+ if (completions.length || signalled) { signalled = false; continue; }
1032
+ if (running.size === 0) {
1033
+ if (ended) return finish('done');
1034
+ if (pauseRequested) return finish('paused');
1035
+ if (held.size === 0 && outstanding.size === 0) return finish('done'); // quiescence
1036
+ }
1037
+ await waitForChange();
1038
+ }
1039
+ }
1040
+
1041
+ return {
1042
+ run,
1043
+ reattach,
1044
+ pause() { pauseRequested = true; wake(); },
1045
+ abort() { abortRequested = true; controller.abort(); wake(); },
1046
+ getState() {
1047
+ return {
1048
+ active: [...running].map(([nodeId, executionId]) => ({ nodeId, executionId })),
1049
+ executions: [...execs.values()].map((e) => ({ ...e })),
1050
+ // Latched OUTPUT tokens (what a wire carries), keyed '<node>.<outputPort>'.
1051
+ tokens: Object.fromEntries([...outputs].map(([k, t]) => [
1052
+ k, { seq: t.seq, type: t.type, path: t.path ?? null, firedAt: t.firedAt },
1053
+ ])),
1054
+ wireDeliveries: Object.fromEntries([...wireState].map(([id, st]) => [id, st.deliveries])),
1055
+ ended: ended ? { ...ended, result: { ...ended.result } } : null,
1056
+ endReached: Boolean(ended),
1057
+ result: ended ? { ...ended.result } : null,
1058
+ warnings: [...warnings],
1059
+ gate: gate ? { ...gate } : null,
1060
+ settled,
1061
+ };
1062
+ },
1063
+ get settled() { return settled; },
1064
+ };
1065
+ }