botmux 2.93.0 → 2.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  2. package/dist/adapters/cli/claude-code.js +6 -1
  3. package/dist/adapters/cli/claude-code.js.map +1 -1
  4. package/dist/bot-registry.d.ts +10 -1
  5. package/dist/bot-registry.d.ts.map +1 -1
  6. package/dist/bot-registry.js +2 -0
  7. package/dist/bot-registry.js.map +1 -1
  8. package/dist/cli/dashboard-endpoint.d.ts +1 -1
  9. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  10. package/dist/cli/dashboard-endpoint.js +3 -0
  11. package/dist/cli/dashboard-endpoint.js.map +1 -1
  12. package/dist/cli/workflow.d.ts.map +1 -1
  13. package/dist/cli/workflow.js +11 -4
  14. package/dist/cli/workflow.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +133 -1
  17. package/dist/cli.js.map +1 -1
  18. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  19. package/dist/core/dashboard-ipc-server.js +3 -0
  20. package/dist/core/dashboard-ipc-server.js.map +1 -1
  21. package/dist/core/inherit-peer.d.ts +5 -0
  22. package/dist/core/inherit-peer.d.ts.map +1 -1
  23. package/dist/core/inherit-peer.js +3 -0
  24. package/dist/core/inherit-peer.js.map +1 -1
  25. package/dist/core/trigger-session.d.ts +10 -0
  26. package/dist/core/trigger-session.d.ts.map +1 -1
  27. package/dist/core/trigger-session.js +22 -2
  28. package/dist/core/trigger-session.js.map +1 -1
  29. package/dist/core/worker-pool.d.ts +6 -0
  30. package/dist/core/worker-pool.d.ts.map +1 -1
  31. package/dist/core/worker-pool.js +49 -6
  32. package/dist/core/worker-pool.js.map +1 -1
  33. package/dist/daemon.d.ts.map +1 -1
  34. package/dist/daemon.js +297 -13
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/dashboard/auth.d.ts.map +1 -1
  37. package/dist/dashboard/auth.js +7 -1
  38. package/dist/dashboard/auth.js.map +1 -1
  39. package/dist/dashboard/bot-payload.d.ts +1 -0
  40. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  41. package/dist/dashboard/bot-payload.js +1 -0
  42. package/dist/dashboard/bot-payload.js.map +1 -1
  43. package/dist/dashboard/federation-spoke-api.d.ts +31 -0
  44. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  45. package/dist/dashboard/federation-spoke-api.js +36 -14
  46. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  47. package/dist/dashboard/v3-runs-api.d.ts +26 -0
  48. package/dist/dashboard/v3-runs-api.d.ts.map +1 -0
  49. package/dist/dashboard/v3-runs-api.js +67 -0
  50. package/dist/dashboard/v3-runs-api.js.map +1 -0
  51. package/dist/dashboard/web/app.js +21 -9
  52. package/dist/dashboard/web/app.js.map +1 -1
  53. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  54. package/dist/dashboard/web/bot-defaults.js +24 -1
  55. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  56. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  57. package/dist/dashboard/web/i18n.js +12 -4
  58. package/dist/dashboard/web/i18n.js.map +1 -1
  59. package/dist/dashboard/web/legacy-workflow-link.d.ts +2 -0
  60. package/dist/dashboard/web/legacy-workflow-link.d.ts.map +1 -0
  61. package/dist/dashboard/web/legacy-workflow-link.js +12 -0
  62. package/dist/dashboard/web/legacy-workflow-link.js.map +1 -0
  63. package/dist/dashboard/web/v3-terminal.d.ts +11 -0
  64. package/dist/dashboard/web/v3-terminal.d.ts.map +1 -0
  65. package/dist/dashboard/web/v3-terminal.js +143 -0
  66. package/dist/dashboard/web/v3-terminal.js.map +1 -0
  67. package/dist/dashboard/web/v3.d.ts +2 -0
  68. package/dist/dashboard/web/v3.d.ts.map +1 -0
  69. package/dist/dashboard/web/v3.js +564 -0
  70. package/dist/dashboard/web/v3.js.map +1 -0
  71. package/dist/dashboard/web/workflows.d.ts.map +1 -1
  72. package/dist/dashboard/web/workflows.js +12 -8
  73. package/dist/dashboard/web/workflows.js.map +1 -1
  74. package/dist/dashboard-web/app.js +787 -699
  75. package/dist/dashboard-web/index.html +1 -1
  76. package/dist/dashboard-web/style.css +223 -0
  77. package/dist/dashboard.js +61 -40
  78. package/dist/dashboard.js.map +1 -1
  79. package/dist/i18n/en.d.ts.map +1 -1
  80. package/dist/i18n/en.js +6 -3
  81. package/dist/i18n/en.js.map +1 -1
  82. package/dist/i18n/zh.d.ts.map +1 -1
  83. package/dist/i18n/zh.js +6 -3
  84. package/dist/i18n/zh.js.map +1 -1
  85. package/dist/im/lark/card-handler.d.ts +12 -0
  86. package/dist/im/lark/card-handler.d.ts.map +1 -1
  87. package/dist/im/lark/card-handler.js +52 -2
  88. package/dist/im/lark/card-handler.js.map +1 -1
  89. package/dist/im/lark/doc-comment.d.ts +20 -0
  90. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  91. package/dist/im/lark/doc-comment.js +23 -0
  92. package/dist/im/lark/doc-comment.js.map +1 -1
  93. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  94. package/dist/im/lark/event-dispatcher.js +13 -9
  95. package/dist/im/lark/event-dispatcher.js.map +1 -1
  96. package/dist/im/lark/v3-blocked-card-handler.d.ts +28 -0
  97. package/dist/im/lark/v3-blocked-card-handler.d.ts.map +1 -0
  98. package/dist/im/lark/v3-blocked-card-handler.js +134 -0
  99. package/dist/im/lark/v3-blocked-card-handler.js.map +1 -0
  100. package/dist/im/lark/v3-blocked-card.d.ts +75 -0
  101. package/dist/im/lark/v3-blocked-card.d.ts.map +1 -0
  102. package/dist/im/lark/v3-blocked-card.js +206 -0
  103. package/dist/im/lark/v3-blocked-card.js.map +1 -0
  104. package/dist/im/lark/v3-gate-card-handler.d.ts +31 -0
  105. package/dist/im/lark/v3-gate-card-handler.d.ts.map +1 -0
  106. package/dist/im/lark/v3-gate-card-handler.js +110 -0
  107. package/dist/im/lark/v3-gate-card-handler.js.map +1 -0
  108. package/dist/im/lark/v3-gate-card.d.ts +47 -0
  109. package/dist/im/lark/v3-gate-card.d.ts.map +1 -0
  110. package/dist/im/lark/v3-gate-card.js +119 -0
  111. package/dist/im/lark/v3-gate-card.js.map +1 -0
  112. package/dist/im/lark/v3-loop-grant-card-handler.d.ts +28 -0
  113. package/dist/im/lark/v3-loop-grant-card-handler.d.ts.map +1 -0
  114. package/dist/im/lark/v3-loop-grant-card-handler.js +90 -0
  115. package/dist/im/lark/v3-loop-grant-card-handler.js.map +1 -0
  116. package/dist/im/lark/v3-loop-grant-card.d.ts +46 -0
  117. package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -0
  118. package/dist/im/lark/v3-loop-grant-card.js +119 -0
  119. package/dist/im/lark/v3-loop-grant-card.js.map +1 -0
  120. package/dist/im/lark/v3-revisit-grant-card-handler.d.ts +29 -0
  121. package/dist/im/lark/v3-revisit-grant-card-handler.d.ts.map +1 -0
  122. package/dist/im/lark/v3-revisit-grant-card-handler.js +96 -0
  123. package/dist/im/lark/v3-revisit-grant-card-handler.js.map +1 -0
  124. package/dist/im/lark/v3-revisit-grant-card.d.ts +52 -0
  125. package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -0
  126. package/dist/im/lark/v3-revisit-grant-card.js +123 -0
  127. package/dist/im/lark/v3-revisit-grant-card.js.map +1 -0
  128. package/dist/im/lark/workflow-cards.d.ts.map +1 -1
  129. package/dist/im/lark/workflow-cards.js +5 -1
  130. package/dist/im/lark/workflow-cards.js.map +1 -1
  131. package/dist/im/lark/workflow-progress-card.d.ts +1 -1
  132. package/dist/im/lark/workflow-progress-card.js +1 -1
  133. package/dist/im/lark/workflow-slash-command.d.ts +28 -0
  134. package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
  135. package/dist/im/lark/workflow-slash-command.js +61 -5
  136. package/dist/im/lark/workflow-slash-command.js.map +1 -1
  137. package/dist/platform/bind.d.ts.map +1 -1
  138. package/dist/platform/bind.js +17 -3
  139. package/dist/platform/bind.js.map +1 -1
  140. package/dist/services/card-prefs-store.d.ts +4 -0
  141. package/dist/services/card-prefs-store.d.ts.map +1 -1
  142. package/dist/services/card-prefs-store.js +19 -0
  143. package/dist/services/card-prefs-store.js.map +1 -1
  144. package/dist/services/group-creator.d.ts.map +1 -1
  145. package/dist/services/group-creator.js +18 -2
  146. package/dist/services/group-creator.js.map +1 -1
  147. package/dist/skills/definitions.d.ts.map +1 -1
  148. package/dist/skills/definitions.js +208 -4
  149. package/dist/skills/definitions.js.map +1 -1
  150. package/dist/worker.js +79 -13
  151. package/dist/worker.js.map +1 -1
  152. package/dist/workflows/definition.d.ts +80 -80
  153. package/dist/workflows/events/payloads.d.ts +4 -4
  154. package/dist/workflows/events/schema.d.ts +16 -16
  155. package/dist/workflows/v3/architect.d.ts +37 -0
  156. package/dist/workflows/v3/architect.d.ts.map +1 -0
  157. package/dist/workflows/v3/architect.js +226 -0
  158. package/dist/workflows/v3/architect.js.map +1 -0
  159. package/dist/workflows/v3/bot-resolve.d.ts +21 -0
  160. package/dist/workflows/v3/bot-resolve.d.ts.map +1 -0
  161. package/dist/workflows/v3/bot-resolve.js +47 -0
  162. package/dist/workflows/v3/bot-resolve.js.map +1 -0
  163. package/dist/workflows/v3/cli-run.d.ts +24 -0
  164. package/dist/workflows/v3/cli-run.d.ts.map +1 -0
  165. package/dist/workflows/v3/cli-run.js +265 -0
  166. package/dist/workflows/v3/cli-run.js.map +1 -0
  167. package/dist/workflows/v3/contract.d.ts +302 -0
  168. package/dist/workflows/v3/contract.d.ts.map +1 -0
  169. package/dist/workflows/v3/contract.js +91 -0
  170. package/dist/workflows/v3/contract.js.map +1 -0
  171. package/dist/workflows/v3/daemon-run.d.ts +311 -0
  172. package/dist/workflows/v3/daemon-run.d.ts.map +1 -0
  173. package/dist/workflows/v3/daemon-run.js +775 -0
  174. package/dist/workflows/v3/daemon-run.js.map +1 -0
  175. package/dist/workflows/v3/dag.d.ts +332 -0
  176. package/dist/workflows/v3/dag.d.ts.map +1 -0
  177. package/dist/workflows/v3/dag.js +1040 -0
  178. package/dist/workflows/v3/dag.js.map +1 -0
  179. package/dist/workflows/v3/ephemeral-pool.d.ts +29 -0
  180. package/dist/workflows/v3/ephemeral-pool.d.ts.map +1 -0
  181. package/dist/workflows/v3/ephemeral-pool.js +331 -0
  182. package/dist/workflows/v3/ephemeral-pool.js.map +1 -0
  183. package/dist/workflows/v3/grill-state.d.ts +92 -0
  184. package/dist/workflows/v3/grill-state.d.ts.map +1 -0
  185. package/dist/workflows/v3/grill-state.js +151 -0
  186. package/dist/workflows/v3/grill-state.js.map +1 -0
  187. package/dist/workflows/v3/host.d.ts +88 -0
  188. package/dist/workflows/v3/host.d.ts.map +1 -0
  189. package/dist/workflows/v3/host.js +349 -0
  190. package/dist/workflows/v3/host.js.map +1 -0
  191. package/dist/workflows/v3/human-gate.d.ts +85 -0
  192. package/dist/workflows/v3/human-gate.d.ts.map +1 -0
  193. package/dist/workflows/v3/human-gate.js +153 -0
  194. package/dist/workflows/v3/human-gate.js.map +1 -0
  195. package/dist/workflows/v3/journal.d.ts +207 -0
  196. package/dist/workflows/v3/journal.d.ts.map +1 -0
  197. package/dist/workflows/v3/journal.js +74 -0
  198. package/dist/workflows/v3/journal.js.map +1 -0
  199. package/dist/workflows/v3/manifest.d.ts +17 -0
  200. package/dist/workflows/v3/manifest.d.ts.map +1 -0
  201. package/dist/workflows/v3/manifest.js +266 -0
  202. package/dist/workflows/v3/manifest.js.map +1 -0
  203. package/dist/workflows/v3/ops-projection.d.ts +110 -0
  204. package/dist/workflows/v3/ops-projection.d.ts.map +1 -0
  205. package/dist/workflows/v3/ops-projection.js +0 -0
  206. package/dist/workflows/v3/ops-projection.js.map +1 -0
  207. package/dist/workflows/v3/orchestrator.d.ts +168 -0
  208. package/dist/workflows/v3/orchestrator.d.ts.map +1 -0
  209. package/dist/workflows/v3/orchestrator.js +417 -0
  210. package/dist/workflows/v3/orchestrator.js.map +1 -0
  211. package/dist/workflows/v3/runtime.d.ts +193 -0
  212. package/dist/workflows/v3/runtime.d.ts.map +1 -0
  213. package/dist/workflows/v3/runtime.js +1284 -0
  214. package/dist/workflows/v3/runtime.js.map +1 -0
  215. package/dist/workflows/v3/spec.d.ts +32 -0
  216. package/dist/workflows/v3/spec.d.ts.map +1 -0
  217. package/dist/workflows/v3/spec.js +173 -0
  218. package/dist/workflows/v3/spec.js.map +1 -0
  219. package/dist/workflows/v3/state.d.ts +74 -0
  220. package/dist/workflows/v3/state.d.ts.map +1 -0
  221. package/dist/workflows/v3/state.js +353 -0
  222. package/dist/workflows/v3/state.js.map +1 -0
  223. package/package.json +1 -1
  224. package/dist/dashboard/web/workflow-catalog.d.ts +0 -2
  225. package/dist/dashboard/web/workflow-catalog.d.ts.map +0 -1
  226. package/dist/dashboard/web/workflow-catalog.js +0 -323
  227. package/dist/dashboard/web/workflow-catalog.js.map +0 -1
@@ -0,0 +1,1284 @@
1
+ /**
2
+ * v3 runtime — the scheduling main loop.
3
+ *
4
+ * Ties the pure pieces together against the SHARED contract:
5
+ * load dag → freeze bot snapshots → init runDir →
6
+ * { materialize journal → decideNext → dispatch ready work under caps →
7
+ * await a settle → repeat } until terminal.
8
+ *
9
+ * Every side effect lives here (journal append, STATE checkpoint, dir layout,
10
+ * goal/inputs/env materialization). The actual worker spawn (`runNode`) and
11
+ * manifest validation (`validateManifest`) are INJECTED — codex's
12
+ * `ephemeral-pool.ts` / `manifest.ts` provide them, but the runtime compiles
13
+ * against the contract types alone so the two halves build independently.
14
+ *
15
+ * MVP scope: static DAG, fail-fast, no retry (always `attempts/001`). Retry
16
+ * (`attempts/NNN`) and richer cancel semantics are deferred — see
17
+ * `docs/design/2026-06-01-v3-mvp-engine-split.md`.
18
+ */
19
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
20
+ import { join, dirname, relative, isAbsolute } from 'node:path';
21
+ import { DEFAULT_NODE_TIMEOUT_SEC, DEFAULT_REVISIT_BUDGET_PER_PAIR, DEFAULT_REVISIT_BUDGET_PER_RUN, isGoalNode, isLoopNode, loopInstanceId, } from './dag.js';
22
+ import { decideNext } from './orchestrator.js';
23
+ import { appendEvent, readJournal } from './journal.js';
24
+ import { materialize, writeState } from './state.js';
25
+ import { normalizeGateWaitInput, writePendingWait } from './human-gate.js';
26
+ import { ASK_HUMAN_ERROR_CODE, GOAL_ASK_FILE, GOAL_ENV, MANIFEST_FILE_KINDS, MANIFEST_SCHEMA_VERSION, MANIFEST_STATUSES, V3_SUPPORTED_CLIS, isV3SupportedCli, } from './contract.js';
27
+ // ─── goal.txt rendering ─────────────────────────────────────────────────────
28
+ /**
29
+ * Render the self-contained instruction file the goal-mode agent reads via
30
+ * `$BOTMUX_GOAL_PATH`. The execution contract (read inputs / write products /
31
+ * write the manifest) lives HERE — in a file — rather than inside the `/goal`
32
+ * command text, because a long multi-line `/goal` argument trips Claude Code's
33
+ * paste-detection (the TUI folds it into a "[Pasted text]" blob and the
34
+ * slash-command parser never fires). The pool's `buildGoalCommand` therefore
35
+ * sends only a short single-line `/goal` that points the agent at this file.
36
+ *
37
+ * Rendered from `contract.ts` constants so the manifest shape stays a single
38
+ * source of truth shared with codex's validator.
39
+ */
40
+ export function renderGoalFile(goal, resultSchema, loopCtx, nodeInstructions) {
41
+ const E = GOAL_ENV;
42
+ const kinds = MANIFEST_FILE_KINDS.join(' | ');
43
+ const [okStatus, failStatus] = MANIFEST_STATUSES;
44
+ const hasEnum = resultSchema && Object.values(resultSchema.properties).some((p) => p.enum);
45
+ const resultSection = resultSchema
46
+ ? [
47
+ '## Structured result (REQUIRED for this node)',
48
+ `This node declares a structured output contract. Write a JSON file \`result.json\` directly under $${E.OUTPUT_DIR} matching this schema (declared property types are enforced at the top level; every \`required\` field must be present):`,
49
+ '',
50
+ ' ' + JSON.stringify(resultSchema),
51
+ '',
52
+ ...(hasEnum
53
+ ? [
54
+ 'Fields declaring an `enum` MUST use one of the listed values EXACTLY (case-sensitive) — downstream routing decisions read these values, and anything outside the vocabulary blocks this node.',
55
+ '',
56
+ ]
57
+ : []),
58
+ `List \`result.json\` in the manifest \`files\` array like any other product (its \`path\` is exactly "result.json"). A missing or schema-violating result.json blocks this node.`,
59
+ '',
60
+ ]
61
+ : [];
62
+ const loopSection = loopCtx
63
+ ? [
64
+ '## Loop context',
65
+ `This node runs inside loop "${loopCtx.loopId}", iteration ${loopCtx.iteration} of at most ${loopCtx.maxIterations}.`,
66
+ ...(loopCtx.iteration > 1
67
+ ? [
68
+ 'Inputs labeled `previous.<node>` are products of the PREVIOUS iteration (e.g. the last test report). Read them FIRST and fix what they describe — do not redo work that already passed, and do not guess what happened last round.',
69
+ ]
70
+ : []),
71
+ 'Report results honestly — a truthful "not passed" routes the rework correctly; a wishful "passed" ships a broken result.',
72
+ '',
73
+ ]
74
+ : [];
75
+ const instructionsSection = nodeInstructions
76
+ ? ['## Node-specific instructions', nodeInstructions, '']
77
+ : [];
78
+ return [
79
+ '# botmux v3 节点任务 / botmux v3 node task',
80
+ '',
81
+ '## Goal',
82
+ goal,
83
+ '',
84
+ ...instructionsSection,
85
+ ...loopSection,
86
+ '## How to complete this node',
87
+ 'You are an autonomous agent completing exactly ONE botmux v3 workflow node.',
88
+ 'Work toward the goal above until it is done, then stop. Do NOT ask the user with interactive tools (they are disabled in this mode). If you genuinely need a human DECISION to proceed, use the human-ask escape hatch described below (also available as the `botmux-goal-ask` skill).',
89
+ '',
90
+ `- Upstream inputs: the file at $${E.INPUTS_PATH} is a JSON object \`{ "inputs": [...] }\` listing upstream products, each with an absolute \`path\`. Read only the ones the goal needs (it may be empty). If it includes an input entry \`{ "from": "human", "name": "answer", "path": "..." }\`, read that JSON file before continuing. If an \`omitted\` array is present, those declared inputs were intentionally not produced (their workflow branch was not taken) — treat their absence as by-design, do NOT invent their content.`,
91
+ `- Revisit feedback: if any input has \`"from": "revisit"\`, a DOWNSTREAM node sent this node back because its product was inadequate. You MUST read these before doing anything else: \`reason\` (why you were sent back), \`source:*\` (the downstream node's output — the evidence of what was wrong), and \`previous:*\` (YOUR OWN previous output — edit/fix it, do not rewrite from scratch). Address the reason; do not just reproduce the prior output.`,
92
+ `- Output: write ALL products under the directory at $${E.OUTPUT_DIR}. Do NOT write anything outside that directory.`,
93
+ `- Manifest (required): before you finish, write a JSON manifest to $${E.MANIFEST_PATH} with exactly this shape:`,
94
+ '',
95
+ ' {',
96
+ ` "schemaVersion": ${MANIFEST_SCHEMA_VERSION},`,
97
+ ` "status": "${okStatus}" | "${failStatus}",`,
98
+ ' "summary": "<one short line>",',
99
+ ' "files": [',
100
+ ` { "name": "<logical name>", "path": "<RELATIVE to the output dir>", "kind": "<${kinds}>", "bytes": <int>, "sha256": "<hex sha256 of the file; empty string \\"\\" for a directory>", "mime": "<mime type>", "preview": "<optional short excerpt>" }`,
101
+ ' ],',
102
+ ` "error": { "code": "...", "message": "...", "retryable": false }`,
103
+ ' }',
104
+ '',
105
+ ` - On success: status "${okStatus}", at least one file entry, and NO \`error\` field.`,
106
+ ` - On failure: status "${failStatus}", \`error\` required, \`files\` may be empty. Set \`error.retryable\` honestly: \`true\` when a human can unblock you and a fresh attempt could then succeed; \`false\` when retrying cannot help.`,
107
+ ` - Every file \`path\` is relative to $${E.OUTPUT_DIR} ITSELF. A file you wrote directly into that directory has a path that is JUST its filename, e.g. \`"path": "report.md"\`. Do NOT prepend the directory or its folder name (NOT \`"work/report.md"\`) and do NOT use an absolute path — both are rejected.`,
108
+ '',
109
+ ...resultSection,
110
+ `You are DONE only after the manifest at $${E.MANIFEST_PATH} exists and every file it references exists.`,
111
+ 'If you cannot complete the goal, write a failure manifest and stop.',
112
+ 'If you hit an authentication / authorization / interactive-confirmation wall (a login prompt, an expired token, a permission you cannot grant yourself): do NOT wait for a human and do NOT keep retrying. Immediately write a failure manifest with an \`error.code\` like "AUTH_REQUIRED" and \`error.retryable: true\`, then stop — a human will unblock and retry this node.',
113
+ '',
114
+ '## Asking a human (only when a DECISION truly needs a person)',
115
+ `If — and ONLY if — you cannot proceed without a human's judgement call (a choice only a person can make; NOT something you can research, infer, or decide yourself), use the runtime human-ask:`,
116
+ ` 1. Write a JSON file to $${E.ATTEMPT_DIR}/${GOAL_ASK_FILE}. Use \`{ "question": "<one clear question>", "options": ["<2-6 concrete choices>"] }\` for a choice, or \`{ "question": "<one clear question>", "freeText": true }\` when the human must provide details in their own words.`,
117
+ ` 2. Write a failure manifest with \`error.code: "${ASK_HUMAN_ERROR_CODE}"\`, \`error.retryable: true\`, and \`summary\` = your question, then STOP.`,
118
+ `A human answers; this node then RE-RUNS with their answer injected into $${E.INPUTS_PATH} as an input entry \`{ "from": "human", "name": "answer", "path": "..." }\`. Read that JSON file's \`selected\` or \`text\` field and continue from there. Prefer deciding yourself — every ask pauses the whole workflow on a person.`,
119
+ '',
120
+ ].join('\n');
121
+ }
122
+ // ─── Terminal classification + structured-result validation (pure) ──────────
123
+ /**
124
+ * Map a node's failure to its terminal kind (the blocked/failed split):
125
+ * - `blocked` = semantic/contract failure — retryable via a new attempt
126
+ * - `failed` = infrastructure / human-veto / budget — needs intervention
127
+ *
128
+ * `selfReportedFail` marks the special case where the manifest is structurally
129
+ * VALID but declares `status:'fail'` — then the node's own `error.retryable`
130
+ * decides (`false` → failed; `true`/absent → blocked, the agent presumably
131
+ * knows a human can unblock it).
132
+ */
133
+ export function classifyTerminal(errorClass, opts) {
134
+ if (opts?.selfReportedFail)
135
+ return opts.retryable === false ? 'failed' : 'blocked';
136
+ switch (errorClass) {
137
+ case 'manifestInvalid': // agent wrote a bad manifest — a retry may fix it
138
+ case 'resultInvalid': // result.json missing/violating — same
139
+ return 'blocked';
140
+ case 'workerError': // process crash = infrastructure
141
+ case 'timeout': // budget exceeded = infrastructure (for now)
142
+ case 'gateRejected': // a human said no — retrying won't change that
143
+ case 'cancelled':
144
+ return 'failed';
145
+ }
146
+ }
147
+ /**
148
+ * Read + validate a goal worker's `ask.json` (the runtime human-ask payload).
149
+ * Defensive: a missing / malformed / out-of-bounds file yields `undefined`, so a
150
+ * broken ask degrades to a plain blocked card rather than crashing the drive —
151
+ * the manifest's `error.message` still carries the question text for the human.
152
+ * Accepts either 2–6 concrete options or `freeText:true`. Exported for tests.
153
+ */
154
+ export function readGoalAsk(askPath) {
155
+ if (!existsSync(askPath))
156
+ return undefined;
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(readFileSync(askPath, 'utf-8'));
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ if (typeof parsed !== 'object' || parsed === null)
165
+ return undefined;
166
+ const o = parsed;
167
+ const question = typeof o.question === 'string' ? o.question.trim() : '';
168
+ if (!question)
169
+ return undefined;
170
+ const hasOptions = Object.prototype.hasOwnProperty.call(o, 'options');
171
+ if (o.freeText === true) {
172
+ if (hasOptions)
173
+ return undefined;
174
+ return { question, freeText: true };
175
+ }
176
+ const options = Array.isArray(o.options)
177
+ ? o.options.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim())
178
+ : [];
179
+ if (options.length < 2)
180
+ return undefined;
181
+ return { question, options: options.slice(0, 6) };
182
+ }
183
+ /**
184
+ * Merge a node's capability override onto the bot's frozen snapshot (P2).
185
+ * Pure + exported for tests. Direction is one-way by construction:
186
+ * - model: node override wins (redirect, not escalation);
187
+ * - disableCliBypass: sticky-true — `restricted` can SET it, nothing can
188
+ * clear a bot-level restriction (`inherit` keeps whatever the bot has).
189
+ */
190
+ export function mergeNodeCapability(snap, override) {
191
+ if (!override)
192
+ return snap;
193
+ return {
194
+ ...snap,
195
+ ...(override.model ? { model: override.model } : {}),
196
+ ...(snap.disableCliBypass === true || override.permissionMode === 'restricted'
197
+ ? { disableCliBypass: true }
198
+ : {}),
199
+ };
200
+ }
201
+ /** Read a worker's cross-node revisit request from `result.json` (if any). A
202
+ * revisit is `{ "status": "revisit", "revisitTo": "<ancestor>", "reason"? }`.
203
+ * Absent result.json / non-revisit status → `{ ok:true }` (no request). A
204
+ * malformed revisit (missing/blank revisitTo, non-string reason) → `ok:false`
205
+ * so the runtime blocks it as resultInvalid. The ancestor membership check
206
+ * (toNodeId ∈ node.revisitTo) is the caller's (it has the node). */
207
+ /** Two-tier revisit budget check (anti-infinite-loop): a source→target pair may
208
+ * revisit `DEFAULT_REVISIT_BUDGET_PER_PAIR` times, and the whole run
209
+ * `DEFAULT_REVISIT_BUDGET_PER_RUN` times, each extendable by a
210
+ * `revisitBudgetGranted` event. Counts revisits ALREADY made; returns
211
+ * `{ok:false, tier, detail}` when this next revisit would exceed a tier —
212
+ * `tier` tells the grant card which scope to extend (菲菲 review). */
213
+ export function revisitBudgetStatus(events, sourceNodeId, toNodeId) {
214
+ let pairUsed = 0;
215
+ let runUsed = 0;
216
+ let pairGranted = 0;
217
+ let runGranted = 0;
218
+ for (const e of events) {
219
+ if (e.type === 'nodeRevisitRequested') {
220
+ runUsed++;
221
+ if (e.nodeId === sourceNodeId && e.toNodeId === toNodeId)
222
+ pairUsed++;
223
+ }
224
+ else if (e.type === 'revisitBudgetGranted') {
225
+ if (e.sourceNodeId === sourceNodeId && e.toNodeId === toNodeId)
226
+ pairGranted++;
227
+ else if (e.sourceNodeId === undefined && e.toNodeId === undefined)
228
+ runGranted++;
229
+ }
230
+ }
231
+ const pairLimit = DEFAULT_REVISIT_BUDGET_PER_PAIR + pairGranted;
232
+ const runLimit = DEFAULT_REVISIT_BUDGET_PER_RUN + runGranted;
233
+ if (pairUsed >= pairLimit) {
234
+ return { ok: false, tier: 'pair', detail: `revisit budget exhausted for ${sourceNodeId}->${toNodeId} (${pairUsed}/${pairLimit}) — grant +1 (this pair) to continue` };
235
+ }
236
+ if (runUsed >= runLimit) {
237
+ return { ok: false, tier: 'run', detail: `run-wide revisit budget exhausted (${runUsed}/${runLimit}) — grant +1 (run) to continue` };
238
+ }
239
+ return { ok: true };
240
+ }
241
+ export function readRevisitRequest(manifest, outputDir) {
242
+ const entry = manifest.files.find((f) => f.path === 'result.json');
243
+ if (!entry)
244
+ return { ok: true };
245
+ let value;
246
+ try {
247
+ value = JSON.parse(readFileSync(join(outputDir, entry.path), 'utf-8'));
248
+ }
249
+ catch {
250
+ return { ok: true }; // unreadable result.json: not a revisit (resultSchema path reports it)
251
+ }
252
+ if (!value || typeof value !== 'object' || value.status !== 'revisit') {
253
+ return { ok: true };
254
+ }
255
+ const v = value;
256
+ const problems = [];
257
+ if (typeof v.revisitTo !== 'string' || v.revisitTo.trim() === '') {
258
+ problems.push('result.json status "revisit" requires a non-empty string "revisitTo"');
259
+ }
260
+ if (v.reason !== undefined && typeof v.reason !== 'string') {
261
+ problems.push('result.json "reason" must be a string when present');
262
+ }
263
+ if (problems.length > 0)
264
+ return { ok: false, problems };
265
+ return {
266
+ ok: true,
267
+ request: {
268
+ toNodeId: v.revisitTo,
269
+ ...(typeof v.reason === 'string' && v.reason ? { reason: v.reason } : {}),
270
+ },
271
+ };
272
+ }
273
+ /**
274
+ * Validate a `result.json` against the node's (already dag-validated) result
275
+ * schema subset. Top-level types only — see `V3ResultSchema`. Undeclared
276
+ * extra properties are allowed (JSON-Schema default).
277
+ */
278
+ export function validateResult(filePath, schema) {
279
+ if (!existsSync(filePath))
280
+ return { ok: false, problems: [`result.json not found at ${filePath}`] };
281
+ let parsed;
282
+ try {
283
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
284
+ }
285
+ catch (err) {
286
+ return { ok: false, problems: [`result.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`] };
287
+ }
288
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
289
+ return { ok: false, problems: ['result.json root must be a JSON object'] };
290
+ }
291
+ const obj = parsed;
292
+ const problems = [];
293
+ for (const field of schema.required ?? []) {
294
+ if (!(field in obj))
295
+ problems.push(`missing required field "${field}"`);
296
+ }
297
+ for (const [name, spec] of Object.entries(schema.properties)) {
298
+ if (!(name in obj))
299
+ continue; // absence is only a problem when required
300
+ const v = obj[name];
301
+ const okType = spec.type === 'string' ? typeof v === 'string'
302
+ : spec.type === 'number' ? typeof v === 'number' && Number.isFinite(v)
303
+ : spec.type === 'boolean' ? typeof v === 'boolean'
304
+ : spec.type === 'array' ? Array.isArray(v)
305
+ : typeof v === 'object' && v !== null && !Array.isArray(v);
306
+ if (!okType) {
307
+ problems.push(`field "${name}" must be of type ${spec.type}`);
308
+ continue;
309
+ }
310
+ // Enum enforcement (edge-activation design §1.3): a declared vocabulary
311
+ // is part of the contract — an out-of-vocabulary value is `resultInvalid`
312
+ // (blocked, retryable), same as a type violation.
313
+ if (spec.type === 'string' && spec.enum && !spec.enum.includes(v)) {
314
+ problems.push(`field "${name}" must be one of [${spec.enum.join(', ')}] (got ${JSON.stringify(v)})`);
315
+ }
316
+ }
317
+ return problems.length > 0 ? { ok: false, problems } : { ok: true };
318
+ }
319
+ /**
320
+ * Evaluate a (dag-validated) loop exit predicate against the observed result
321
+ * field. Pure + exported for tests. Type mismatches simply don't match —
322
+ * validateDag already guarantees the field is declared/required with a
323
+ * compatible type, so a mismatch here means the result was tampered with
324
+ * post-validation; not-matching (→ continue/exhausted) is the safe answer.
325
+ */
326
+ export function matchLoopExitWhen(when, value) {
327
+ if (when.equals !== undefined)
328
+ return value === when.equals;
329
+ if (when.notEquals !== undefined)
330
+ return value !== when.notEquals;
331
+ if (typeof value !== 'number')
332
+ return false;
333
+ if (when.gt !== undefined)
334
+ return value > when.gt;
335
+ if (when.gte !== undefined)
336
+ return value >= when.gte;
337
+ if (when.lt !== undefined)
338
+ return value < when.lt;
339
+ if (when.lte !== undefined)
340
+ return value <= when.lte;
341
+ return false;
342
+ }
343
+ // ─── Attempt numbering (journal-derived — no hardcoded 001) ──────────────────
344
+ const ATTEMPT_NNN_RE = /\/attempts\/(\d{3})$/;
345
+ function attemptNumber(attemptId) {
346
+ const m = ATTEMPT_NNN_RE.exec(attemptId);
347
+ return m ? parseInt(m[1], 10) : undefined;
348
+ }
349
+ /**
350
+ * Compute the attemptId the NEXT dispatch of `nodeId` must use, from the
351
+ * journal: an unconsumed `nodeRetryRequested` reservation wins (retry intent
352
+ * is authoritative for the redrive); otherwise max(seen)+1 — which is 001 for
353
+ * a first dispatch. Dispatch events are the authority for "seen"; a
354
+ * reservation is consumed by a later `nodeDispatched` with the same number.
355
+ */
356
+ export function nextAttemptIdFor(events, key) {
357
+ // `key` is the dispatch namespace: a runtime instance (`A#001`), a loop body
358
+ // expansion (`loopId.i001.code`), or a legacy nodeId. Match events by their
359
+ // instance when they carry one, else by nodeId — so `A#002`'s attempts are
360
+ // counted separately from `A#001`'s (constraint 3/5).
361
+ const matches = (e) => (e.instanceId ?? e.nodeId) === key;
362
+ let maxSeen = 0;
363
+ let reserved;
364
+ for (const e of events) {
365
+ if (e.type === 'nodeDispatched' && matches(e)) {
366
+ const n = attemptNumber(e.attemptId);
367
+ if (n === undefined)
368
+ continue;
369
+ maxSeen = Math.max(maxSeen, n);
370
+ if (reserved === n)
371
+ reserved = undefined; // reservation consumed
372
+ }
373
+ else if (e.type === 'nodeRetryRequested' && matches(e)) {
374
+ const n = attemptNumber(e.nextAttemptId);
375
+ if (n === undefined)
376
+ continue;
377
+ reserved = n;
378
+ maxSeen = Math.max(maxSeen, n);
379
+ }
380
+ }
381
+ const n = reserved ?? maxSeen + 1;
382
+ return `${key}/attempts/${String(n).padStart(3, '0')}`;
383
+ }
384
+ /** Latest dispatched attemptId for a dispatch `key` (the `previousAttemptId` a
385
+ * retry entrypoint must reference). `key` is an instance (`A#001`), a loop
386
+ * body expansion, or a legacy nodeId — matched by `(instanceId ?? nodeId)` so
387
+ * a retry stays inside the same instance. Undefined when never dispatched. */
388
+ export function latestAttemptIdFor(events, key) {
389
+ let latest;
390
+ for (const e of events) {
391
+ if (e.type === 'nodeDispatched' && (e.instanceId ?? e.nodeId) === key)
392
+ latest = e.attemptId;
393
+ }
394
+ return latest;
395
+ }
396
+ // ─── Main loop ───────────────────────────────────────────────────────────
397
+ /**
398
+ * Run a validated DAG to terminal. Resumable: if `journal.ndjson` already has
399
+ * events (daemon restart), the loop picks up from the materialized state
400
+ * instead of re-running completed nodes.
401
+ */
402
+ export async function runWorkflow(dag, deps, opts) {
403
+ const runDir = join(opts.baseDir, dag.runId);
404
+ mkdirSync(runDir, { recursive: true });
405
+ const journalPath = join(runDir, 'journal.ndjson');
406
+ const statePath = join(runDir, 'STATE');
407
+ const globalCap = opts.globalConcurrency ?? 4;
408
+ const perBotCap = opts.perBotConcurrency ?? 1;
409
+ const perCliCap = opts.perCliConcurrency ?? 2;
410
+ const gateMode = opts.gateMode ?? 'blocking';
411
+ const nodesById = new Map(dag.nodes.map((n) => [n.id, n]));
412
+ // Freeze bot snapshots once, keyed by the node's `bot` field (''=default),
413
+ // and persist for audit / resume. Re-resolving mid-run would let a drifted
414
+ // bots.json change cliId/model/workingDir under a retry (codex point 1).
415
+ // Loop body nodes are frozen too (a body node inherits the loop's bot when
416
+ // it has none of its own — mirror instanceNodeFor's resolution).
417
+ const botSnapshots = new Map();
418
+ const freezeBot = (bot) => {
419
+ const key = bot ?? '';
420
+ if (!botSnapshots.has(key))
421
+ botSnapshots.set(key, deps.resolveBotSnapshot(bot));
422
+ };
423
+ for (const node of dag.nodes) {
424
+ freezeBot(node.bot);
425
+ if (isLoopNode(node)) {
426
+ for (const b of node.body.nodes)
427
+ freezeBot(b.bot ?? node.bot);
428
+ }
429
+ }
430
+ // CLI-scope guard (老滕 directive): goal-mode rides the native `/goal`
431
+ // command, which only Claude Code / Codex support. Fail the whole run up
432
+ // front — clearly — rather than spawning a worker on an unsupported CLI that
433
+ // would never understand `/goal`.
434
+ for (const [key, snap] of botSnapshots) {
435
+ if (!isV3SupportedCli(snap.cliId)) {
436
+ throw new Error(`v3 runtime: bot "${key || '<default>'}" resolves to CLI "${snap.cliId}", ` +
437
+ `which is not supported by v3 goal-mode (supported: ${V3_SUPPORTED_CLIS.join(', ')})`);
438
+ }
439
+ }
440
+ writeFileSync(join(runDir, 'bots.snapshot.json'), JSON.stringify(Object.fromEntries(botSnapshots), null, 2));
441
+ // Persist the dag into the runDir so the dashboard projection can read the
442
+ // node graph (depends → edges) and a resume is self-describing. Deterministic
443
+ // (same runId ⇒ same dag), so re-writing on resume is harmless.
444
+ writeFileSync(join(runDir, 'dag.json'), JSON.stringify(dag, null, 2));
445
+ // First run only: stamp runStarted (idempotent on resume).
446
+ if (readJournal(journalPath).length === 0) {
447
+ appendEvent(journalPath, { type: 'runStarted', runId: dag.runId });
448
+ }
449
+ // In-flight bookkeeping. Work uses the nodeId as the key; gates use
450
+ // `${nodeId}::gate` so a gated node's work + gate never collide.
451
+ const inFlight = new Map();
452
+ const botInFlight = new Map();
453
+ const cliInFlight = new Map();
454
+ const nodeControllers = new Map();
455
+ const nodeAbortCleanups = new Map();
456
+ while (true) {
457
+ const events = readJournal(journalPath);
458
+ const snap = materialize(events);
459
+ writeState(statePath, snap);
460
+ if (snap.runStatus !== 'running')
461
+ break;
462
+ const actions = decideNext(dag, snap.nodes, snap.loops, snap.edges, snap.instances);
463
+ // Terminal sweep: write the run terminal event, then re-tick so the top of
464
+ // the loop observes it and breaks (single exit path).
465
+ const terminal = actions.find((a) => a.kind === 'completeRunSucceeded' ||
466
+ a.kind === 'completeRunFailed' ||
467
+ a.kind === 'completeRunBlocked');
468
+ if (terminal) {
469
+ if (terminal.kind === 'completeRunSucceeded') {
470
+ appendEvent(journalPath, { type: 'runSucceeded' });
471
+ }
472
+ else if (terminal.kind === 'completeRunFailed') {
473
+ appendEvent(journalPath, {
474
+ type: 'runFailed',
475
+ failedNodeId: terminal.failedNodeId,
476
+ reason: terminal.reason,
477
+ detail: terminal.detail,
478
+ });
479
+ }
480
+ else {
481
+ appendEvent(journalPath, { type: 'runBlocked', blockedNodeId: terminal.blockedNodeId });
482
+ }
483
+ continue;
484
+ }
485
+ // Control sweep: each action is one cheap journal append (no worker
486
+ // involved), applied together and re-ticked — same single-exit shape as
487
+ // the terminal sweep. Work dispatches in the same action list simply
488
+ // re-emerge next tick. Edge resolution is deliberately serial/control
489
+ // phase (H8): no inFlight, no concurrency slot, no AbortController.
490
+ const controls = actions.filter((a) => a.kind === 'startLoop' ||
491
+ a.kind === 'startLoopIteration' ||
492
+ a.kind === 'evaluateLoopIteration' ||
493
+ a.kind === 'completeLoop' ||
494
+ a.kind === 'resolveEdge' ||
495
+ a.kind === 'skipNode');
496
+ if (controls.length > 0) {
497
+ const eventsForControl = readJournal(journalPath);
498
+ for (const a of controls) {
499
+ if (a.kind === 'resolveEdge')
500
+ applyResolveEdge(a, eventsForControl);
501
+ else if (a.kind === 'skipNode') {
502
+ appendEvent(journalPath, {
503
+ type: 'nodeSkipped',
504
+ nodeId: a.nodeId,
505
+ reason: 'triggerRuleUnsatisfied',
506
+ detail: a.detail,
507
+ });
508
+ }
509
+ else
510
+ applyLoopControl(a);
511
+ }
512
+ continue;
513
+ }
514
+ // Dispatch the ready set under the three-layer cap. Anything not started
515
+ // this tick (cap hit) is retried next tick.
516
+ let startedThisTick = 0;
517
+ const aborted = opts.cancelSignal?.aborted === true;
518
+ if (!aborted) {
519
+ for (const a of actions) {
520
+ if (inFlight.size >= globalCap)
521
+ break;
522
+ if (a.kind === 'dispatchWork') {
523
+ // Loop body instances are synthesized from the body definition; the
524
+ // instance id is theirs alone (attempt dirs, journal events, retry).
525
+ const node = a.loop ? instanceNodeFor(a.loop) : nodesById.get(a.nodeId);
526
+ const botKey = node.bot ?? '';
527
+ const botSnap = botSnapshots.get(botKey);
528
+ if ((botInFlight.get(botKey) ?? 0) >= perBotCap)
529
+ continue;
530
+ if ((cliInFlight.get(botSnap.cliId) ?? 0) >= perCliCap)
531
+ continue;
532
+ startWork(node, botSnap, botKey, events, a.loop, a.omitted, a.instanceId);
533
+ startedThisTick++;
534
+ }
535
+ else if (a.kind === 'dispatchGate') {
536
+ startGate(nodesById.get(a.nodeId), a.instanceId);
537
+ startedThisTick++;
538
+ }
539
+ }
540
+ }
541
+ const cancels = actions.filter((a) => a.kind === 'cancelNode');
542
+ let cancelledThisTick = false;
543
+ for (const a of cancels) {
544
+ cancelledThisTick = applyCancelNode(a) || cancelledThisTick;
545
+ }
546
+ if (cancelledThisTick)
547
+ continue;
548
+ if (inFlight.size === 0) {
549
+ if (aborted)
550
+ break; // cancelled with nothing running → stop
551
+ if (startedThisTick === 0) {
552
+ const pendingWaits = gateMode === 'suspend' ? pendingGateWaits(snap.nodes) : [];
553
+ if (pendingWaits.length > 0) {
554
+ return { reason: 'awaitingGate', pendingWaits, runDir };
555
+ }
556
+ // Not terminal, nothing running, nothing dispatchable — a correct
557
+ // decideNext never gets here; guard against an infinite spin.
558
+ throw new Error('v3 runtime: no progress possible and run is not terminal');
559
+ }
560
+ }
561
+ // Wait for at least one in-flight unit to settle before re-evaluating.
562
+ if (inFlight.size > 0)
563
+ await Promise.race(inFlight.values());
564
+ }
565
+ const finalSnap = materialize(readJournal(journalPath));
566
+ return {
567
+ reason: 'terminal',
568
+ // Map 1:1 — blocked must NOT collapse into failed. A 'running' snapshot
569
+ // here means the loop exited via cancel-abort with nothing in flight;
570
+ // report that as failed (the run did not complete).
571
+ runStatus: finalSnap.runStatus === 'succeeded' ? 'succeeded'
572
+ : finalSnap.runStatus === 'blocked' ? 'blocked'
573
+ : 'failed',
574
+ failedNodeId: finalSnap.failedNodeId,
575
+ failureReason: finalSnap.failureReason,
576
+ failureDetail: finalSnap.failureDetail,
577
+ blockedNodeId: finalSnap.blockedNodeId,
578
+ runDir,
579
+ };
580
+ // ─── closures over runDir / journalPath / caps ──────────────────────────
581
+ function startWork(node, botSnap, botKey, events, loopRef, omitted, instanceId) {
582
+ // The dispatch key namespaces the attempt dir + journal events. For a
583
+ // plain node it's the runtime instance (`A#001`); for a loop body it's the
584
+ // expanded node.id (`loopId.i001.code`); legacy/no-instance falls back to
585
+ // node.id. attempt dir = `<runDir>/<key>/attempts/NNN`.
586
+ const dispatchKey = instanceId ?? node.id;
587
+ // Attempt number derived from the journal: 001 on first dispatch, the
588
+ // reserved nextAttemptId after a blocked retry (no hardcoded 001 — a retry
589
+ // must not overwrite the previous attempt's logs/manifest/pty).
590
+ const attemptId = nextAttemptIdFor(events, dispatchKey);
591
+ const attemptNNN = attemptId.slice(attemptId.lastIndexOf('/') + 1);
592
+ const attemptDir = join(runDir, dispatchKey, 'attempts', attemptNNN);
593
+ const outputDir = join(attemptDir, 'work');
594
+ mkdirSync(outputDir, { recursive: true });
595
+ const goalPath = join(attemptDir, 'goal.txt');
596
+ const loopCtx = loopRef
597
+ ? {
598
+ loopId: loopRef.loopId,
599
+ iteration: loopRef.iteration,
600
+ maxIterations: nodesById.get(loopRef.loopId).maxIterations,
601
+ }
602
+ : undefined;
603
+ writeFileSync(goalPath, renderGoalFile(node.goal ?? '', node.resultSchema, loopCtx, node.override?.systemPromptAppend));
604
+ // P2: per-dispatch capability merge — model redirect + sticky restriction.
605
+ const effSnap = mergeNodeCapability(botSnap, node.override);
606
+ const inputsPath = join(attemptDir, 'inputs.json');
607
+ writeFileSync(inputsPath, JSON.stringify(buildInputs(node, events, attemptId, loopRef, omitted), null, 2));
608
+ const manifestPath = join(attemptDir, 'manifest.json');
609
+ const env = {
610
+ [GOAL_ENV.GOAL_PATH]: goalPath,
611
+ [GOAL_ENV.INPUTS_PATH]: inputsPath,
612
+ [GOAL_ENV.OUTPUT_DIR]: outputDir,
613
+ [GOAL_ENV.MANIFEST_PATH]: manifestPath,
614
+ [GOAL_ENV.ATTEMPT_DIR]: attemptDir,
615
+ [GOAL_ENV.V3_MARKER]: '1',
616
+ };
617
+ appendEvent(journalPath, { type: 'nodeDispatched', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId, loop: loopRef });
618
+ botInFlight.set(botKey, (botInFlight.get(botKey) ?? 0) + 1);
619
+ cliInFlight.set(botSnap.cliId, (cliInFlight.get(botSnap.cliId) ?? 0) + 1);
620
+ // `isGoalNode` is guaranteed by validateDag (host is rejected), but the
621
+ // contract types `runNode` to V3GoalNode, so narrow explicitly.
622
+ if (!isGoalNode(node)) {
623
+ appendEvent(journalPath, {
624
+ type: 'nodeFailed', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
625
+ errorClass: 'workerError', message: `node "${node.id}" is not a goal node`,
626
+ });
627
+ releaseSlots(botKey, botSnap.cliId);
628
+ return;
629
+ }
630
+ const controller = new AbortController();
631
+ const relayAbort = () => controller.abort();
632
+ if (opts.cancelSignal?.aborted)
633
+ relayAbort();
634
+ else
635
+ opts.cancelSignal?.addEventListener('abort', relayAbort, { once: true });
636
+ nodeControllers.set(dispatchKey, controller);
637
+ nodeAbortCleanups.set(dispatchKey, () => opts.cancelSignal?.removeEventListener('abort', relayAbort));
638
+ const req = {
639
+ runId: dag.runId,
640
+ attemptId,
641
+ node,
642
+ botSnapshot: effSnap,
643
+ runDir,
644
+ attemptDir,
645
+ inputsPath,
646
+ outputDir,
647
+ env,
648
+ timeoutMs: (node.timeoutSec ?? DEFAULT_NODE_TIMEOUT_SEC) * 1000,
649
+ cancelSignal: controller.signal,
650
+ // Worker terminal is ready mid-run → stamp nodeSessionReady so the
651
+ // dashboard can attach to the LIVE terminal. Sync appendEvent (no await
652
+ // on the pool's fire-and-forget ready path — codex note).
653
+ onSessionReady: (info) => {
654
+ // Drop the write `token` — never persist it (codex security review):
655
+ // the dashboard view is read-only and doesn't need write access.
656
+ appendEvent(journalPath, {
657
+ type: 'nodeSessionReady',
658
+ nodeId: node.id,
659
+ ...(instanceId ? { instanceId } : {}),
660
+ attemptId,
661
+ sessionInfo: { sessionId: info.sessionId, webPort: info.webPort },
662
+ ptyLogPath: info.ptyLogPath,
663
+ });
664
+ },
665
+ };
666
+ const p = deps
667
+ .runNode(req)
668
+ .then(async (result) => {
669
+ // Final verdict = process outcome AND manifest validation (codex
670
+ // point 4 — NOT v0.2 final_output semantics). Always validate the
671
+ // manifest so a clean `status:'fail'` manifest yields a precise
672
+ // root cause instead of an opaque process error (codex's advice).
673
+ const verdict = await deps.validateManifest(result.manifestPath, outputDir);
674
+ const manifestSaysOk = verdict.ok && verdict.manifest?.status === 'ok';
675
+ if (result.status === 'ok' && manifestSaysOk) {
676
+ // Cross-node revisit: the worker's result.json may request a jump back
677
+ // to an ancestor (`status:"revisit", revisitTo, reason`). Recognized
678
+ // BEFORE success/resultSchema — a revisit is not a node success.
679
+ const revisit = readRevisitRequest(verdict.manifest, outputDir);
680
+ if (!revisit.ok) {
681
+ appendEvent(journalPath, {
682
+ type: 'nodeBlocked', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
683
+ errorClass: 'resultInvalid', message: revisit.problems.join('; '),
684
+ });
685
+ return;
686
+ }
687
+ if (revisit.request) {
688
+ if (!node.revisitTo?.includes(revisit.request.toNodeId)) {
689
+ appendEvent(journalPath, {
690
+ type: 'nodeBlocked', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
691
+ errorClass: 'resultInvalid',
692
+ message: `result.json requests revisit to "${revisit.request.toNodeId}", not in node "${node.id}".revisitTo`,
693
+ });
694
+ return;
695
+ }
696
+ // Anti-infinite-loop: a revisit consumes per-pair + per-run budget.
697
+ // Exhausted → block this node (recoverable) instead of superseding;
698
+ // a human grants +1 (revisitBudgetGranted) then retries.
699
+ const budget = revisitBudgetStatus(readJournal(journalPath), node.id, revisit.request.toNodeId);
700
+ if (!budget.ok) {
701
+ appendEvent(journalPath, {
702
+ type: 'nodeBlocked', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
703
+ errorClass: 'resultInvalid', errorCode: 'REVISIT_BUDGET_EXHAUSTED', message: budget.detail,
704
+ revisitTo: revisit.request.toNodeId,
705
+ });
706
+ return;
707
+ }
708
+ // goal-node dispatches always carry instanceId (首派 #001); the
709
+ // fallback keeps the type total for the legacy/no-instance path.
710
+ appendRevisitEvents(node.id, instanceId ?? node.id, attemptId, revisit.request, result.manifestPath);
711
+ return;
712
+ }
713
+ // Opt-in structured-result contract: the manifest MUST list a
714
+ // `result.json` entry (so it went through the manifest validator's
715
+ // path/hash checks like every other product), and the file must
716
+ // match the node's schema. A violation BLOCKS (retryable), it does
717
+ // not fail.
718
+ if (node.resultSchema) {
719
+ const entry = verdict.manifest.files.find((f) => f.path === 'result.json');
720
+ if (!entry) {
721
+ appendEvent(journalPath, {
722
+ type: 'nodeBlocked', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
723
+ errorClass: 'resultInvalid',
724
+ message: 'node declares resultSchema but its manifest lists no "result.json" file',
725
+ });
726
+ return;
727
+ }
728
+ const res = validateResult(join(outputDir, entry.path), node.resultSchema);
729
+ if (!res.ok) {
730
+ appendEvent(journalPath, {
731
+ type: 'nodeBlocked', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
732
+ errorClass: 'resultInvalid',
733
+ message: (res.problems ?? ['result.json failed schema validation']).join('; '),
734
+ });
735
+ return;
736
+ }
737
+ }
738
+ appendEvent(journalPath, {
739
+ type: 'nodeSucceeded', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId, manifestPath: result.manifestPath,
740
+ });
741
+ return;
742
+ }
743
+ let errorClass;
744
+ let message;
745
+ let errorCode;
746
+ let selfReportedFail = false;
747
+ let retryable;
748
+ if (!verdict.ok) {
749
+ // Manifest missing / malformed. If the process itself also failed,
750
+ // the worker crash is the root cause; otherwise it's a bad manifest.
751
+ errorClass = result.status === 'ok' ? 'manifestInvalid' : 'workerError';
752
+ message = (verdict.problems ?? ['manifest missing or invalid']).join('; ');
753
+ }
754
+ else {
755
+ // Manifest is structurally valid but declares failure (or the
756
+ // process failed despite an ok manifest) — surface the node's own
757
+ // error when present. A self-reported fail is the agent's "I am
758
+ // blocked, a human can fix this" channel (e.g. AUTH_REQUIRED with
759
+ // retryable:true), so it feeds the blocked/failed split below.
760
+ const m = verdict.manifest;
761
+ errorClass = 'workerError';
762
+ if (m.status === 'fail' && m.error) {
763
+ message = `${m.error.code}: ${m.error.message}`;
764
+ errorCode = m.error.code;
765
+ if (result.status === 'ok') {
766
+ // Only an intact worker's self-report counts; a crashed process
767
+ // with a leftover fail manifest is still an infrastructure error.
768
+ selfReportedFail = true;
769
+ retryable = m.error.retryable;
770
+ }
771
+ }
772
+ else {
773
+ message = 'runNode reported process failure';
774
+ }
775
+ }
776
+ const kind = classifyTerminal(errorClass, { selfReportedFail, retryable });
777
+ if (kind === 'blocked') {
778
+ // Runtime human-ask: a self-reported block carrying ASK_HUMAN_ERROR_CODE
779
+ // means the agent wrote a question to ask.json and stopped. Surface
780
+ // question + options on the blocked event so the daemon posts an ask
781
+ // card; a malformed ask.json degrades to a plain retry card.
782
+ const ask = errorCode === ASK_HUMAN_ERROR_CODE
783
+ ? readGoalAsk(join(attemptDir, GOAL_ASK_FILE))
784
+ : undefined;
785
+ appendEvent(journalPath, {
786
+ type: 'nodeBlocked',
787
+ nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId, errorClass, errorCode, message,
788
+ ...(ask ? { ask } : {}),
789
+ });
790
+ }
791
+ else {
792
+ appendEvent(journalPath, {
793
+ type: 'nodeFailed',
794
+ nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId, errorClass, errorCode, message,
795
+ });
796
+ }
797
+ })
798
+ .catch((err) => {
799
+ appendEvent(journalPath, {
800
+ type: 'nodeFailed', nodeId: node.id, ...(instanceId ? { instanceId } : {}), attemptId,
801
+ errorClass: 'workerError', message: err instanceof Error ? err.message : String(err),
802
+ });
803
+ })
804
+ .finally(() => {
805
+ // Key by dispatchKey (instance-scoped), NOT node.id: after a cross-node
806
+ // revisit, D#001 and D#002 can be in-flight at once under the same
807
+ // node.id. An unguarded inFlight.delete(node.id) here would let the stale
808
+ // D#001's settle remove the LIVE D#002 entry → a healthy run trips the
809
+ // "no progress possible" guard and crashes. Guard mirrors the controller
810
+ // delete below.
811
+ if (inFlight.get(dispatchKey) === p)
812
+ inFlight.delete(dispatchKey);
813
+ if (nodeControllers.get(dispatchKey) === controller) {
814
+ nodeControllers.delete(dispatchKey);
815
+ nodeAbortCleanups.get(dispatchKey)?.();
816
+ nodeAbortCleanups.delete(dispatchKey);
817
+ }
818
+ releaseSlots(botKey, botSnap.cliId);
819
+ });
820
+ inFlight.set(dispatchKey, p);
821
+ }
822
+ /** A node's transitive downstream cone (the node itself + every node reachable
823
+ * via `depends` edges). The set a revisit to `root` must refresh: `root`'s
824
+ * product changed, so every result derived from it is stale. */
825
+ function affectedNodesFrom(root) {
826
+ const reachable = new Set([root]);
827
+ let changed = true;
828
+ while (changed) {
829
+ changed = false;
830
+ for (const n of dag.nodes) {
831
+ if (reachable.has(n.id))
832
+ continue;
833
+ if (n.depends.some((d) => reachable.has(d.from))) {
834
+ reachable.add(n.id);
835
+ changed = true;
836
+ }
837
+ }
838
+ }
839
+ return [...reachable];
840
+ }
841
+ /** A worker requested a cross-node revisit to ancestor `toNodeId`: journal the
842
+ * request, then supersede the CURRENT effective instance of the target AND its
843
+ * whole downstream cone (mark-only, files kept). materialize then drops their
844
+ * effectiveInstanceId → decideNext re-dispatches fresh `#NNN` instances. */
845
+ function appendRevisitEvents(nodeId, instanceId, attemptId, request, sourceManifestPath) {
846
+ // Capture feedback paths BEFORE the supersede sweep, while the target's
847
+ // current effective instance + its successful manifest are still resolvable.
848
+ const events0 = readJournal(journalPath);
849
+ const snap = materialize(events0);
850
+ const targetEff = snap.nodes.get(request.toNodeId)?.effectiveInstanceId;
851
+ const targetSucc = targetEff
852
+ ? [...events0].reverse().find((e) => e.type === 'nodeSucceeded' && (e.instanceId ?? e.nodeId) === targetEff)
853
+ : undefined;
854
+ // Persist the reason as a file the target's fresh instance can Read. The
855
+ // journal stores runDir-RELATIVE paths (run-dir portability + no abs-path
856
+ // leakage into projections/cards); buildInputs resolves to absolute on read.
857
+ let reasonPathRel;
858
+ if (request.reason) {
859
+ const dir = join(runDir, 'revisits');
860
+ mkdirSync(dir, { recursive: true });
861
+ const abs = join(dir, `${instanceId.replace(/[#/]/g, '-')}-reason.md`);
862
+ writeFileSync(abs, `# Revisit reason (from ${nodeId} / ${instanceId})\n\n${request.reason}\n`);
863
+ reasonPathRel = relative(runDir, abs);
864
+ }
865
+ appendEvent(journalPath, {
866
+ type: 'nodeRevisitRequested',
867
+ nodeId, instanceId, attemptId, toNodeId: request.toNodeId,
868
+ ...(request.reason ? { reason: request.reason } : {}),
869
+ ...(reasonPathRel ? { reasonPath: reasonPathRel } : {}),
870
+ sourceManifestPath: relative(runDir, sourceManifestPath),
871
+ ...(targetSucc ? { targetPreviousManifestPath: relative(runDir, targetSucc.manifestPath) } : {}),
872
+ });
873
+ for (const affectedNodeId of affectedNodesFrom(request.toNodeId)) {
874
+ const eff = snap.nodes.get(affectedNodeId)?.effectiveInstanceId;
875
+ if (!eff)
876
+ continue; // never-dispatched downstream node has nothing to supersede
877
+ appendEvent(journalPath, {
878
+ type: 'nodeInstanceSuperseded',
879
+ nodeId: affectedNodeId, instanceId: eff, byNodeId: request.toNodeId, reason: 'refresh',
880
+ });
881
+ }
882
+ }
883
+ function startGate(node, instanceId) {
884
+ // Instance-level waitId so a revisit's fresh gate (`A#002-gate`) gets its own
885
+ // wait file + card nonce, never overwriting the superseded `A#001-gate`
886
+ // (stale-card protection). Legacy/no-instance → `<nodeId>-gate`.
887
+ const waitId = `${instanceId ?? node.id}-gate`;
888
+ const gate = normalizeGateWaitInput(node.humanGate);
889
+ appendEvent(journalPath, { type: 'gateDispatched', nodeId: node.id, ...(instanceId ? { instanceId } : {}), waitId });
890
+ if (gateMode === 'suspend') {
891
+ writePendingWait(runDir, { waitId, nodeId: node.id, ...(instanceId ? { instanceId } : {}), ...gate });
892
+ return;
893
+ }
894
+ if (!deps.resolveGate) {
895
+ throw new Error(`v3 runtime: node "${node.id}" has a humanGate but no resolveGate handler was injected`);
896
+ }
897
+ // Instance-scoped, like the work path (and the waitId above): a cross-node
898
+ // revisit can leave D#001's gate in flight while D#002's gate is re-dispatched
899
+ // under the same node.id. Keying by node.id + an unguarded delete would let
900
+ // D#001's gate settle remove the LIVE D#002 entry → "no progress possible" crash.
901
+ const key = `${instanceId ?? node.id}::gate`;
902
+ const p = deps
903
+ .resolveGate({ nodeId: node.id, prompt: gate.prompt, waitId, runDir })
904
+ .then(({ resolution, by, selected }) => {
905
+ // Carry instanceId (mirror gateDispatched + the daemon suspend path): a
906
+ // gateResolved WITHOUT it falls into state.ts's legacy per-node branch,
907
+ // so a late D#001 gate resolve after a revisit re-dispatched D#002 would
908
+ // overwrite node D's view → pollute the live D#002 instance.
909
+ appendEvent(journalPath, { type: 'gateResolved', nodeId: node.id, ...(instanceId ? { instanceId } : {}), waitId, resolution, by, selected });
910
+ })
911
+ .catch(() => {
912
+ // A gate that errors out is treated as rejected (fail-fast); the
913
+ // run-failure root cause is the rejection, recorded on the journal.
914
+ appendEvent(journalPath, { type: 'gateResolved', nodeId: node.id, ...(instanceId ? { instanceId } : {}), waitId, resolution: 'rejected', by: 'system' });
915
+ })
916
+ .finally(() => {
917
+ if (inFlight.get(key) === p)
918
+ inFlight.delete(key);
919
+ });
920
+ inFlight.set(key, p);
921
+ }
922
+ /** Synthesize the effective node a body instance runs as: the body
923
+ * definition re-id'd into the iteration namespace, with internal deps /
924
+ * inputs mapped to instance ids and the bot inherited from the loop. */
925
+ function instanceNodeFor(ref) {
926
+ const loopNode = nodesById.get(ref.loopId);
927
+ const bodyDef = loopNode.body.nodes.find((b) => b.id === ref.bodyNodeId);
928
+ return {
929
+ ...bodyDef,
930
+ id: loopInstanceId(ref.loopId, ref.iteration, ref.bodyNodeId),
931
+ bot: bodyDef.bot ?? loopNode.bot,
932
+ depends: bodyDef.depends.map((d) => ({ from: loopInstanceId(ref.loopId, ref.iteration, d.from) })),
933
+ inputs: bodyDef.inputs.map((r) => ({
934
+ from: loopInstanceId(ref.loopId, ref.iteration, r.from),
935
+ ...(r.select ? { select: r.select } : {}), // P3: 实例化时保留 selector
936
+ })),
937
+ };
938
+ }
939
+ /** Translate one loop-control action into its single journal append. */
940
+ function applyLoopControl(a) {
941
+ if (a.kind === 'startLoop') {
942
+ appendEvent(journalPath, { type: 'loopStarted', loopId: a.loopId });
943
+ return;
944
+ }
945
+ if (a.kind === 'startLoopIteration') {
946
+ appendEvent(journalPath, { type: 'loopIterationStarted', loopId: a.loopId, iteration: a.iteration });
947
+ return;
948
+ }
949
+ const loopNode = nodesById.get(a.loopId);
950
+ if (a.kind === 'completeLoop') {
951
+ // Seal the loop with a nodeSucceeded on the LOOP id carrying the output
952
+ // projection's manifest — downstream deps gating + buildInputs then
953
+ // treat the loop exactly like any done node.
954
+ const events = readJournal(journalPath);
955
+ const outInstId = loopInstanceId(a.loopId, a.iteration, loopNode.output.from);
956
+ const succ = [...events]
957
+ .reverse()
958
+ .find((e) => e.type === 'nodeSucceeded' && e.nodeId === outInstId);
959
+ if (!succ) {
960
+ // Engine anomaly — the decision said exit but the output instance has
961
+ // no success record. Fail loudly rather than fabricate a product.
962
+ appendEvent(journalPath, {
963
+ type: 'nodeFailed', nodeId: a.loopId, attemptId: `${a.loopId}/iterations/${String(a.iteration).padStart(3, '0')}`,
964
+ errorClass: 'workerError',
965
+ message: `loop "${a.loopId}" decided exit but output node "${outInstId}" has no nodeSucceeded`,
966
+ });
967
+ return;
968
+ }
969
+ appendEvent(journalPath, {
970
+ type: 'nodeSucceeded', nodeId: a.loopId, attemptId: succ.attemptId, manifestPath: succ.manifestPath,
971
+ });
972
+ return;
973
+ }
974
+ // evaluateLoopIteration: read the exit instance's structured result and
975
+ // record the decision. Every input here was already validated when the
976
+ // exit node succeeded (resultSchema is mandatory on the exit node), so an
977
+ // unreadable result is an engine anomaly → 'exhausted' (blocks for a
978
+ // human) rather than a silent extra round.
979
+ const events = readJournal(journalPath);
980
+ const exitInstId = loopInstanceId(a.loopId, a.iteration, loopNode.exit.node);
981
+ const succ = [...events]
982
+ .reverse()
983
+ .find((e) => e.type === 'nodeSucceeded' && e.nodeId === exitInstId);
984
+ const key = loopNode.exit.when.path.slice('result.'.length);
985
+ let matched = false;
986
+ let observed = `${loopNode.exit.when.path}=<unreadable>`;
987
+ if (succ) {
988
+ try {
989
+ const manifest = JSON.parse(readFileSync(succ.manifestPath, 'utf-8'));
990
+ const entry = manifest.files.find((f) => f.path === 'result.json');
991
+ if (entry) {
992
+ const result = JSON.parse(readFileSync(join(dirname(succ.manifestPath), 'work', entry.path), 'utf-8'));
993
+ observed = `${loopNode.exit.when.path}=${JSON.stringify(result[key])}`;
994
+ matched = matchLoopExitWhen(loopNode.exit.when, result[key]);
995
+ }
996
+ }
997
+ catch {
998
+ // fall through with matched=false, observed=<unreadable>
999
+ }
1000
+ }
1001
+ const granted = events.filter((e) => e.type === 'loopIterationGranted' && e.loopId === a.loopId).length;
1002
+ const effectiveMax = loopNode.maxIterations + granted;
1003
+ const anomalous = observed.endsWith('<unreadable>');
1004
+ const decision = matched ? 'exit' : !anomalous && a.iteration < effectiveMax ? 'continue' : 'exhausted';
1005
+ appendEvent(journalPath, {
1006
+ type: 'loopIterationDecision', loopId: a.loopId, iteration: a.iteration, decision,
1007
+ detail: `${observed} (iteration ${a.iteration}/${effectiveMax})`,
1008
+ });
1009
+ }
1010
+ /** Resolve one conditional edge by reading the source's latest successful
1011
+ * result.json exactly once, then journaling the boolean verdict. */
1012
+ function applyResolveEdge(a, events) {
1013
+ // Scope the verdict to the CURRENT effective instances of source/target so a
1014
+ // revisit's `A#001->B#001` verdict never bleeds onto `A#002->B#002`
1015
+ // (constraint 1). Legacy/no-instance falls back to the bare nodeId.
1016
+ const snap = materialize(events);
1017
+ const fromInstanceId = snap.nodes.get(a.from)?.effectiveInstanceId;
1018
+ const toInstanceId = snap.nodes.get(a.to)?.effectiveInstanceId;
1019
+ const fromKey = fromInstanceId ?? a.from;
1020
+ const instPair = { ...(fromInstanceId ? { fromInstanceId } : {}), ...(toInstanceId ? { toInstanceId } : {}) };
1021
+ const target = nodesById.get(a.to);
1022
+ const dep = target?.depends.find((d) => d.from === a.from);
1023
+ if (!target || !dep?.when) {
1024
+ appendEvent(journalPath, {
1025
+ type: 'edgeResolved',
1026
+ from: a.from,
1027
+ to: a.to,
1028
+ ...instPair,
1029
+ sourceAttemptId: latestAttemptIdFor(events, fromKey) ?? `${fromKey}/attempts/unknown`,
1030
+ active: false,
1031
+ detail: 'edge predicate missing at resolution time',
1032
+ });
1033
+ return;
1034
+ }
1035
+ const succ = [...events]
1036
+ .reverse()
1037
+ .find((e) => e.type === 'nodeSucceeded' && (e.instanceId ?? e.nodeId) === fromKey);
1038
+ const sourceAttemptId = succ?.attemptId ?? `${fromKey}/attempts/unknown`;
1039
+ const key = dep.when.path.slice('result.'.length);
1040
+ let active = false;
1041
+ let detail = `${dep.when.path}=<unreadable>`;
1042
+ if (succ) {
1043
+ try {
1044
+ const manifest = JSON.parse(readFileSync(succ.manifestPath, 'utf-8'));
1045
+ const entry = manifest.files.find((f) => f.path === 'result.json');
1046
+ if (entry) {
1047
+ const result = JSON.parse(readFileSync(join(dirname(succ.manifestPath), 'work', entry.path), 'utf-8'));
1048
+ detail = `${dep.when.path}=${JSON.stringify(result[key])}`;
1049
+ active = matchLoopExitWhen(dep.when, result[key]);
1050
+ }
1051
+ }
1052
+ catch {
1053
+ // The source's resultSchema should make this unreachable; keep the run
1054
+ // progressing deterministically and surface the anomaly in detail.
1055
+ }
1056
+ }
1057
+ appendEvent(journalPath, {
1058
+ type: 'edgeResolved',
1059
+ from: a.from,
1060
+ to: a.to,
1061
+ ...instPair,
1062
+ sourceAttemptId,
1063
+ active,
1064
+ detail,
1065
+ });
1066
+ }
1067
+ function applyCancelNode(a) {
1068
+ const events = readJournal(journalPath);
1069
+ const snap = materialize(events);
1070
+ const status = snap.nodes.get(a.nodeId)?.status ?? 'pending';
1071
+ if (status !== 'pending' && status !== 'gateWaiting' && status !== 'running')
1072
+ return false;
1073
+ // Stamp the cancelled INSTANCE so a later instance (`A#002` after a revisit)
1074
+ // settles freely — the cancel suppression in materialize keys by instance.
1075
+ const instanceId = snap.nodes.get(a.nodeId)?.effectiveInstanceId;
1076
+ const attemptId = latestAttemptIdFor(events, instanceId ?? a.nodeId);
1077
+ appendEvent(journalPath, {
1078
+ type: 'nodeCancelled',
1079
+ nodeId: a.nodeId,
1080
+ ...(instanceId ? { instanceId } : {}),
1081
+ attemptId,
1082
+ reason: 'earlyReleaseLoser',
1083
+ byNodeId: a.byNodeId,
1084
+ detail: a.detail,
1085
+ });
1086
+ nodeControllers.get(instanceId ?? a.nodeId)?.abort();
1087
+ // Match startGate's instance-scoped gate key so the right instance's gate
1088
+ // in-flight entry is cleared (not a stale node.id-keyed one that never existed).
1089
+ const gateKey = `${instanceId ?? a.nodeId}::gate`;
1090
+ if (inFlight.has(gateKey))
1091
+ inFlight.delete(gateKey);
1092
+ return true;
1093
+ }
1094
+ function pendingGateWaits(state) {
1095
+ const waits = [];
1096
+ for (const node of dag.nodes) {
1097
+ if (state.get(node.id)?.status !== 'gateWaiting')
1098
+ continue;
1099
+ const prompt = node.humanGate?.prompt;
1100
+ if (!prompt)
1101
+ continue;
1102
+ const gate = normalizeGateWaitInput(node.humanGate);
1103
+ // Instance-level waitId mirrors startGate (stale-card protection).
1104
+ const instanceId = state.get(node.id)?.effectiveInstanceId;
1105
+ waits.push({ nodeId: node.id, waitId: `${instanceId ?? node.id}-gate`, ...gate });
1106
+ }
1107
+ return waits;
1108
+ }
1109
+ function releaseSlots(botKey, cliId) {
1110
+ botInFlight.set(botKey, Math.max(0, (botInFlight.get(botKey) ?? 1) - 1));
1111
+ cliInFlight.set(cliId, Math.max(0, (cliInFlight.get(cliId) ?? 1) - 1));
1112
+ }
1113
+ /** Resolve a node's upstream products into its `GoalInputs` (absolute paths).
1114
+ * Reads each upstream's already-validated manifest from the latest
1115
+ * `nodeSucceeded` event; the manifest's relative `path` is joined onto the
1116
+ * upstream outputDir (`<manifestDir>/work`) to produce an absolute path the
1117
+ * downstream agent can Read directly.
1118
+ *
1119
+ * Loop body instances additionally receive (a) the LOOP's outer inputs —
1120
+ * every body node may read what the loop consumes — and (b) from iteration
1121
+ * 2 on, the previous iteration's `feedback` products, labeled
1122
+ * `previous.<bodyId>` so the agent can tell rework context from fresh
1123
+ * upstream input.
1124
+ *
1125
+ * `omitted` (edge-activation design §6): declared inputs the engine layer
1126
+ * determined must NOT be injected (edge inactive / source skipped) — they
1127
+ * are excluded from resolution AND surfaced to the agent so the absence
1128
+ * reads as by-design. Empty/absent → exactly today's behavior. */
1129
+ function buildInputs(node, events, attemptId, loopRef, omitted) {
1130
+ const inputs = [];
1131
+ const omittedFrom = new Set((omitted ?? []).map((o) => o.from));
1132
+ // Resolve upstream products by the source's CURRENT effective instance, NOT
1133
+ // by nodeId-latest (stale-instance blocker): after a revisit, a stale `A#001` worker
1134
+ // can settle LATE; nodeId-latest would then hand `A#001`'s old product to
1135
+ // `B#002`. Keying by effectiveInstanceId pins it to `A#002`.
1136
+ const snap = materialize(events);
1137
+ // Runtime human-ask answer: when THIS dispatch is the retry a human-ask was
1138
+ // answered into, inject the persisted answer as `{from:'human', name:'answer'}`
1139
+ // so the agent reads the decision and resumes instead of re-asking.
1140
+ const answeredRetry = [...events].reverse().find((e) => e.type === 'nodeRetryRequested' && e.nodeId === node.id &&
1141
+ e.nextAttemptId === attemptId && !!e.answer);
1142
+ if (answeredRetry?.answer) {
1143
+ inputs.push({
1144
+ from: 'human',
1145
+ name: 'answer',
1146
+ path: answeredRetry.answer.path,
1147
+ kind: 'json',
1148
+ preview: answeredRetry.answer.preview,
1149
+ });
1150
+ }
1151
+ // Latest success for a dispatch `key` (an effective instance `A#002`, a loop
1152
+ // body expansion, or a legacy nodeId), matched by `(instanceId ?? nodeId)`.
1153
+ const latestSuccess = (key) => [...events]
1154
+ .reverse()
1155
+ .find((e) => e.type === 'nodeSucceeded' && (e.instanceId ?? e.nodeId) === key);
1156
+ const pushFrom = (label, nodeId, filter) => {
1157
+ // Pin to the source's current effective instance (falls back to nodeId for
1158
+ // loop bodies / legacy with no instance).
1159
+ const key = snap.nodes.get(nodeId)?.effectiveInstanceId ?? nodeId;
1160
+ const succ = latestSuccess(key);
1161
+ if (!succ)
1162
+ return; // deps are gated upstream — defensive skip
1163
+ const upstreamOutputDir = join(dirname(succ.manifestPath), 'work');
1164
+ const manifest = JSON.parse(readFileSync(succ.manifestPath, 'utf-8'));
1165
+ for (const f of manifest.files) {
1166
+ if (filter && !filter(f))
1167
+ continue;
1168
+ inputs.push({
1169
+ from: label,
1170
+ ...(succ.instanceId ? { instanceId: succ.instanceId } : {}),
1171
+ name: f.name,
1172
+ path: join(upstreamOutputDir, f.path),
1173
+ kind: f.kind,
1174
+ preview: f.preview,
1175
+ });
1176
+ }
1177
+ };
1178
+ // Push every file of a manifest at a known (absolute) path — used for
1179
+ // revisit feedback where the manifest is referenced directly off the
1180
+ // nodeRevisitRequested event (requester / target-prior), not via a
1181
+ // nodeSucceeded lookup. Names are prefixed so the agent can tell the
1182
+ // feedback pieces apart (`revisit/source:…`, `revisit/previous:…`).
1183
+ // Journal stores runDir-relative revisit paths; resolve to absolute for read.
1184
+ const resolveRunPath = (p) => (isAbsolute(p) ? p : join(runDir, p));
1185
+ const pushManifestByPath = (label, manifestPath, namePrefix) => {
1186
+ if (!manifestPath)
1187
+ return;
1188
+ const abs = resolveRunPath(manifestPath);
1189
+ if (!existsSync(abs))
1190
+ return;
1191
+ let manifest;
1192
+ try {
1193
+ manifest = JSON.parse(readFileSync(abs, 'utf-8'));
1194
+ }
1195
+ catch {
1196
+ return;
1197
+ }
1198
+ const outDir = join(dirname(abs), 'work');
1199
+ for (const f of manifest.files) {
1200
+ inputs.push({
1201
+ from: label,
1202
+ name: `${namePrefix}:${f.name}`,
1203
+ path: join(outDir, f.path),
1204
+ kind: f.kind,
1205
+ preview: f.preview,
1206
+ });
1207
+ }
1208
+ };
1209
+ // P3 selector misses collected during resolution — merged into `omitted`
1210
+ // so the agent reads the gap as a known contract issue, not silence.
1211
+ const selectorMisses = [];
1212
+ const pushRef = (ref) => {
1213
+ const filter = ref.select
1214
+ ? (f) => ref.select.name !== undefined ? f.name === ref.select.name : f.path === ref.select.path
1215
+ : undefined;
1216
+ const before = inputs.length;
1217
+ pushFrom(ref.from, ref.from, filter);
1218
+ if (ref.select && inputs.length === before) {
1219
+ selectorMisses.push({ from: ref.from, reason: 'selectorMiss' });
1220
+ }
1221
+ };
1222
+ for (const ref of node.inputs) {
1223
+ if (omittedFrom.has(ref.from))
1224
+ continue; // branch not taken — surfaced via `omitted`
1225
+ pushRef(ref);
1226
+ }
1227
+ // Cross-node revisit feedback: when THIS node is a revisit target, its fresh
1228
+ // instance is sent back blind unless we hand it (1) WHY it was sent back,
1229
+ // (2) the requester's output (where it went wrong), (3) its OWN prior output
1230
+ // (so it edits rather than rewrites). All as `from:"revisit"` inputs; the
1231
+ // goal.txt instructs the agent to read them first. A plain first run / a
1232
+ // cone node that wasn't the target has no such event → nothing injected.
1233
+ const revisitReq = [...events].reverse().find((e) => e.type === 'nodeRevisitRequested' && e.toNodeId === node.id);
1234
+ if (revisitReq) {
1235
+ if (revisitReq.reasonPath) {
1236
+ inputs.push({ from: 'revisit', name: 'reason', path: resolveRunPath(revisitReq.reasonPath), kind: 'markdown', preview: revisitReq.reason });
1237
+ }
1238
+ pushManifestByPath('revisit', revisitReq.sourceManifestPath, 'source');
1239
+ pushManifestByPath('revisit', revisitReq.targetPreviousManifestPath, 'previous');
1240
+ }
1241
+ if (loopRef) {
1242
+ const loopNode = nodesById.get(loopRef.loopId);
1243
+ // (a) The loop's outer inputs (e.g. `prepare`'s products) flow into
1244
+ // every body instance of every iteration.
1245
+ for (const ref of loopNode.inputs)
1246
+ pushRef(ref);
1247
+ // (b) Declared previous-iteration feedback.
1248
+ if (loopRef.iteration > 1) {
1249
+ for (const fb of loopNode.feedback) {
1250
+ const dot = fb.lastIndexOf('.');
1251
+ const bodyId = fb.slice(0, dot);
1252
+ const kind = fb.slice(dot + 1);
1253
+ const prevInstId = loopInstanceId(loopRef.loopId, loopRef.iteration - 1, bodyId);
1254
+ const label = `previous.${bodyId}`;
1255
+ if (kind === 'manifest') {
1256
+ const succ = latestSuccess(prevInstId);
1257
+ if (succ) {
1258
+ inputs.push({ from: label, name: 'manifest', path: succ.manifestPath, kind: 'json' });
1259
+ }
1260
+ }
1261
+ else {
1262
+ // 'result' → just result.json; 'files' → the whole product set.
1263
+ pushFrom(label, prevInstId, kind === 'result' ? (f) => f.path === 'result.json' : undefined);
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+ // Dedupe by (label, path) — `feedback: ["test.result", "test.files"]`
1269
+ // legitimately overlaps on result.json; one entry is enough.
1270
+ const seen = new Set();
1271
+ const allOmitted = [...(omitted ?? []), ...selectorMisses];
1272
+ return {
1273
+ inputs: inputs.filter((i) => {
1274
+ const key = JSON.stringify([i.from, i.path]);
1275
+ if (seen.has(key))
1276
+ return false;
1277
+ seen.add(key);
1278
+ return true;
1279
+ }),
1280
+ ...(allOmitted.length > 0 ? { omitted: allOmitted } : {}),
1281
+ };
1282
+ }
1283
+ }
1284
+ //# sourceMappingURL=runtime.js.map