@revisium/orchestrator 0.1.0-alpha.9 → 0.2.0-alpha.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 (249) hide show
  1. package/README.md +210 -146
  2. package/control-plane/bootstrap.config.json +43 -1
  3. package/control-plane/default-playbook/catalog/pipelines.json +1157 -10
  4. package/control-plane/default-playbook/catalog/roles.json +54 -0
  5. package/control-plane/default-playbook/package.json +1 -1
  6. package/control-plane/default-playbook/prompts/developer.md +1 -0
  7. package/control-plane/default-playbook/prompts/triager.md +3 -0
  8. package/dist/api/graphql-api/graphql-ws/pubsub.module.js +0 -1
  9. package/dist/api/graphql-api/graphql-ws/pubsub.module.js.map +1 -1
  10. package/dist/api/graphql-api/graphql-ws/run-progress-subscription-poller.service.js +0 -1
  11. package/dist/api/graphql-api/graphql-ws/run-progress-subscription-poller.service.js.map +1 -1
  12. package/dist/api/graphql-api/graphql-ws/subscription-bridge.service.js +0 -1
  13. package/dist/api/graphql-api/graphql-ws/subscription-bridge.service.js.map +1 -1
  14. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
  15. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
  16. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
  17. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
  18. package/dist/api/graphql-api/pr/model/pr-readiness.model.js +26 -0
  19. package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
  20. package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
  21. package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
  22. package/dist/api/graphql-api/runs/model/run.model.js +6 -0
  23. package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
  24. package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
  25. package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
  26. package/dist/cli/commands/doctor-report.js +0 -18
  27. package/dist/cli/commands/doctor-report.js.map +1 -1
  28. package/dist/cli/commands/lifecycle.js +3 -71
  29. package/dist/cli/commands/lifecycle.js.map +1 -1
  30. package/dist/cli/commands/mcp.js +0 -14
  31. package/dist/cli/commands/mcp.js.map +1 -1
  32. package/dist/cli/commands/process-tree.js +4 -26
  33. package/dist/cli/commands/process-tree.js.map +1 -1
  34. package/dist/cli/commands/revisium-helpers.js +0 -6
  35. package/dist/cli/commands/revisium-helpers.js.map +1 -1
  36. package/dist/cli/commands/rogue-reaper.js +0 -34
  37. package/dist/cli/commands/rogue-reaper.js.map +1 -1
  38. package/dist/cli/index.js +0 -4
  39. package/dist/cli/index.js.map +1 -1
  40. package/dist/cli/program.js +0 -5
  41. package/dist/cli/program.js.map +1 -1
  42. package/dist/config.js +0 -25
  43. package/dist/config.js.map +1 -1
  44. package/dist/control-plane/bootstrap.js +12 -27
  45. package/dist/control-plane/bootstrap.js.map +1 -1
  46. package/dist/control-plane/change-notifications.js +0 -1
  47. package/dist/control-plane/change-notifications.js.map +1 -1
  48. package/dist/control-plane/client-transport.js +1 -12
  49. package/dist/control-plane/client-transport.js.map +1 -1
  50. package/dist/control-plane/default-playbook-policy.js +593 -0
  51. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  52. package/dist/control-plane/definitions.js +8 -17
  53. package/dist/control-plane/definitions.js.map +1 -1
  54. package/dist/control-plane/inbox.js +2 -120
  55. package/dist/control-plane/inbox.js.map +1 -1
  56. package/dist/control-plane/resolve-cwd.js +0 -61
  57. package/dist/control-plane/resolve-cwd.js.map +1 -1
  58. package/dist/control-plane/schema-migration.js +0 -9
  59. package/dist/control-plane/schema-migration.js.map +1 -1
  60. package/dist/control-plane/seed-default-playbook.js +50 -46
  61. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  62. package/dist/control-plane/steps.js +0 -15
  63. package/dist/control-plane/steps.js.map +1 -1
  64. package/dist/e2e/kit/agents.js +15 -21
  65. package/dist/e2e/kit/agents.js.map +1 -1
  66. package/dist/e2e/kit/assertions.js +12 -8
  67. package/dist/e2e/kit/assertions.js.map +1 -1
  68. package/dist/e2e/kit/fake-integrator.js +11 -2
  69. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  70. package/dist/e2e/kit/git-target-repo.js +1 -1
  71. package/dist/engine/dbos.service.js +12 -179
  72. package/dist/engine/dbos.service.js.map +1 -1
  73. package/dist/engine/ensure-postgres.js +0 -48
  74. package/dist/engine/ensure-postgres.js.map +1 -1
  75. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  76. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  77. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  78. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  79. package/dist/host/daemon.js +2 -21
  80. package/dist/host/daemon.js.map +1 -1
  81. package/dist/host/dbos-identity.js +0 -31
  82. package/dist/host/dbos-identity.js.map +1 -1
  83. package/dist/host/ensure-host.js +1 -37
  84. package/dist/host/ensure-host.js.map +1 -1
  85. package/dist/host/ensure-revisium.js +1 -135
  86. package/dist/host/ensure-revisium.js.map +1 -1
  87. package/dist/host/host-runtime.js +0 -28
  88. package/dist/host/host-runtime.js.map +1 -1
  89. package/dist/host/host.lifecycle.js +0 -27
  90. package/dist/host/host.lifecycle.js.map +1 -1
  91. package/dist/host/queue-ownership.js +0 -42
  92. package/dist/host/queue-ownership.js.map +1 -1
  93. package/dist/host/queue-poller-census.js +1 -28
  94. package/dist/host/queue-poller-census.js.map +1 -1
  95. package/dist/http/graphql-host.module.js +0 -3
  96. package/dist/http/graphql-host.module.js.map +1 -1
  97. package/dist/mcp/mcp-capabilities.js +23 -5
  98. package/dist/mcp/mcp-capabilities.js.map +1 -1
  99. package/dist/mcp/mcp-facade.service.js +335 -22
  100. package/dist/mcp/mcp-facade.service.js.map +1 -1
  101. package/dist/mcp/mcp-http.service.js +0 -18
  102. package/dist/mcp/mcp-http.service.js.map +1 -1
  103. package/dist/mcp/mcp-tools.js +41 -36
  104. package/dist/mcp/mcp-tools.js.map +1 -1
  105. package/dist/observability/activity-signal.js +103 -0
  106. package/dist/observability/activity-signal.js.map +1 -0
  107. package/dist/observability/agent-activity-reporter.js +0 -4
  108. package/dist/observability/agent-activity-reporter.js.map +1 -1
  109. package/dist/observability/index.js +1 -0
  110. package/dist/observability/index.js.map +1 -1
  111. package/dist/pipeline/await-human.js +0 -40
  112. package/dist/pipeline/await-human.js.map +1 -1
  113. package/dist/pipeline/data-driven-task.workflow.js +563 -251
  114. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  115. package/dist/pipeline/data-driven-template.js +0 -25
  116. package/dist/pipeline/data-driven-template.js.map +1 -1
  117. package/dist/pipeline/pipeline.module.js +0 -12
  118. package/dist/pipeline/pipeline.module.js.map +1 -1
  119. package/dist/pipeline/pipeline.service.js +194 -189
  120. package/dist/pipeline/pipeline.service.js.map +1 -1
  121. package/dist/pipeline/route-contract.js +4 -3
  122. package/dist/pipeline/route-contract.js.map +1 -1
  123. package/dist/pipeline-core/index.js +1 -13
  124. package/dist/pipeline-core/index.js.map +1 -1
  125. package/dist/pipeline-core/interpret.js +21 -94
  126. package/dist/pipeline-core/interpret.js.map +1 -1
  127. package/dist/pipeline-core/kit/assertions.js +0 -19
  128. package/dist/pipeline-core/kit/assertions.js.map +1 -1
  129. package/dist/pipeline-core/kit/builders.js +1 -21
  130. package/dist/pipeline-core/kit/builders.js.map +1 -1
  131. package/dist/pipeline-core/kit/drive.js +1 -31
  132. package/dist/pipeline-core/kit/drive.js.map +1 -1
  133. package/dist/pipeline-core/kit/fixtures.js +78 -101
  134. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  135. package/dist/pipeline-core/kit/index.js +0 -8
  136. package/dist/pipeline-core/kit/index.js.map +1 -1
  137. package/dist/pipeline-core/types.js +13 -52
  138. package/dist/pipeline-core/types.js.map +1 -1
  139. package/dist/pipeline-core/validate-capability.js +0 -3
  140. package/dist/pipeline-core/validate-capability.js.map +1 -1
  141. package/dist/pipeline-core/validate-conflicts.js +0 -9
  142. package/dist/pipeline-core/validate-conflicts.js.map +1 -1
  143. package/dist/pipeline-core/validate-dataflow.js +0 -17
  144. package/dist/pipeline-core/validate-dataflow.js.map +1 -1
  145. package/dist/pipeline-core/validate-diff.js +3 -36
  146. package/dist/pipeline-core/validate-diff.js.map +1 -1
  147. package/dist/pipeline-core/validate-edges.js +0 -6
  148. package/dist/pipeline-core/validate-edges.js.map +1 -1
  149. package/dist/pipeline-core/validate-graph.js +0 -11
  150. package/dist/pipeline-core/validate-graph.js.map +1 -1
  151. package/dist/pipeline-core/validate-loops.js +37 -44
  152. package/dist/pipeline-core/validate-loops.js.map +1 -1
  153. package/dist/pipeline-core/validate-parallel.js +9 -15
  154. package/dist/pipeline-core/validate-parallel.js.map +1 -1
  155. package/dist/pipeline-core/validate-sink.js +0 -7
  156. package/dist/pipeline-core/validate-sink.js.map +1 -1
  157. package/dist/pipeline-core/validate-topology.js +1 -20
  158. package/dist/pipeline-core/validate-topology.js.map +1 -1
  159. package/dist/pipeline-core/validate-verdict.js +52 -7
  160. package/dist/pipeline-core/validate-verdict.js.map +1 -1
  161. package/dist/pipeline-core/validate.js +5 -29
  162. package/dist/pipeline-core/validate.js.map +1 -1
  163. package/dist/playbook/catalog-loader.js +8 -3
  164. package/dist/playbook/catalog-loader.js.map +1 -1
  165. package/dist/playbook/import-mapper.js +4 -5
  166. package/dist/playbook/import-mapper.js.map +1 -1
  167. package/dist/poller/pr-readiness-core.js +226 -29
  168. package/dist/poller/pr-readiness-core.js.map +1 -1
  169. package/dist/revisium/inbox.service.js +0 -24
  170. package/dist/revisium/inbox.service.js.map +1 -1
  171. package/dist/revisium/playbooks.service.js +15 -2
  172. package/dist/revisium/playbooks.service.js.map +1 -1
  173. package/dist/revisium/revisium.module.js +0 -12
  174. package/dist/revisium/revisium.module.js.map +1 -1
  175. package/dist/revisium/roles.service.js +0 -6
  176. package/dist/revisium/roles.service.js.map +1 -1
  177. package/dist/revisium/run.service.js +2 -75
  178. package/dist/revisium/run.service.js.map +1 -1
  179. package/dist/revisium/tokens.js +0 -4
  180. package/dist/revisium/tokens.js.map +1 -1
  181. package/dist/run/append-event.js +6 -37
  182. package/dist/run/append-event.js.map +1 -1
  183. package/dist/run/block-run.js.map +1 -1
  184. package/dist/run/cancel-run.js +0 -33
  185. package/dist/run/cancel-run.js.map +1 -1
  186. package/dist/run/complete-run.js.map +1 -1
  187. package/dist/run/create-run.js +5 -8
  188. package/dist/run/create-run.js.map +1 -1
  189. package/dist/run/fail-run.js +0 -3
  190. package/dist/run/fail-run.js.map +1 -1
  191. package/dist/run/inspect-run.js +10 -29
  192. package/dist/run/inspect-run.js.map +1 -1
  193. package/dist/run/issue-ref.js +88 -0
  194. package/dist/run/issue-ref.js.map +1 -0
  195. package/dist/run/run-outputs.js +0 -19
  196. package/dist/run/run-outputs.js.map +1 -1
  197. package/dist/run/terminal-run-status.js +0 -12
  198. package/dist/run/terminal-run-status.js.map +1 -1
  199. package/dist/runners/claude-code.service.js +2 -13
  200. package/dist/runners/claude-code.service.js.map +1 -1
  201. package/dist/runners/codex.service.js +2 -11
  202. package/dist/runners/codex.service.js.map +1 -1
  203. package/dist/runners/gh-identity.js +0 -52
  204. package/dist/runners/gh-identity.js.map +1 -1
  205. package/dist/runners/integrator-branch-naming.js +16 -19
  206. package/dist/runners/integrator-branch-naming.js.map +1 -1
  207. package/dist/runners/integrator-git.js +0 -25
  208. package/dist/runners/integrator-git.js.map +1 -1
  209. package/dist/runners/integrator-remote.js +0 -2
  210. package/dist/runners/integrator-remote.js.map +1 -1
  211. package/dist/runners/integrator-types.js +0 -7
  212. package/dist/runners/integrator-types.js.map +1 -1
  213. package/dist/runners/integrator.js +231 -230
  214. package/dist/runners/integrator.js.map +1 -1
  215. package/dist/runners/runner.module.js +0 -6
  216. package/dist/runners/runner.module.js.map +1 -1
  217. package/dist/runners/tokens.js +0 -4
  218. package/dist/runners/tokens.js.map +1 -1
  219. package/dist/runners/worktree.service.js +3 -19
  220. package/dist/runners/worktree.service.js.map +1 -1
  221. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  222. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  223. package/dist/task-control-plane/run-watch.service.js +170 -63
  224. package/dist/task-control-plane/run-watch.service.js.map +1 -1
  225. package/dist/task-control-plane/task-control-plane-api.service.js +285 -40
  226. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  227. package/dist/worker/artifact-store.js +21 -3
  228. package/dist/worker/artifact-store.js.map +1 -1
  229. package/dist/worker/build-context.js +10 -10
  230. package/dist/worker/build-context.js.map +1 -1
  231. package/dist/worker/claude-code-runner.js +76 -48
  232. package/dist/worker/claude-code-runner.js.map +1 -1
  233. package/dist/worker/codex-runner.js +128 -35
  234. package/dist/worker/codex-runner.js.map +1 -1
  235. package/dist/worker/git-worktree-manager.js +20 -67
  236. package/dist/worker/git-worktree-manager.js.map +1 -1
  237. package/dist/worker/process-executor.js +144 -27
  238. package/dist/worker/process-executor.js.map +1 -1
  239. package/dist/worker/result-envelope.js +33 -49
  240. package/dist/worker/result-envelope.js.map +1 -1
  241. package/dist/worker/runner-common.js +29 -0
  242. package/dist/worker/runner-common.js.map +1 -1
  243. package/dist/worker/runner-dispatch.js +0 -3
  244. package/dist/worker/runner-dispatch.js.map +1 -1
  245. package/dist/worker/runner.js +16 -1
  246. package/dist/worker/runner.js.map +1 -1
  247. package/dist/worker/stub-runner.js +0 -7
  248. package/dist/worker/stub-runner.js.map +1 -1
  249. package/package.json +2 -2
@@ -1,61 +1,94 @@
1
- /**
2
- * data-driven-task.workflow.ts — the DBOS effect-adapter for a DATA-DRIVEN pipeline (plan 0015 §10).
3
- *
4
- * Runs the pure `pipeline-core` graph on REAL DBOS. As of slice 3 this is the SOLE pipeline engine:
5
- * selection routes EVERY pipeline here (TaskControlPlaneApiService), executing the state-machine
6
- * template pinned for the run; a pipeline without a valid template FAILS LOUD at selection. The old
7
- * hardcoded `developTask` workflow + its role→phase classifiers were removed.
8
- *
9
- * INVARIANT: `src/pipeline/*` imports NO `@dbos-inc/dbos-sdk` (M1 — DBOS sealed). All DBOS interaction
10
- * goes through the generic DbosService verbs (registerStep/registerWorkflow/awaitDecision) injected as
11
- * deps. This file is registered via the engine seam in PipelineService.
12
- *
13
- * GENERIC ENGINE: zero role-ids / pipeline shapes here. `roleRef`/`scriptRef` are opaque capability
14
- * handles resolved against the run's route bindings + the existing runner machinery (§1/§10).
15
- *
16
- * The loop (§10):
17
- * { state, decision } = core.step(template, state, lastResult)
18
- * → execute `decision` as a durable DBOS step (runner / human gate / integrator / fork)
19
- * → validate result vs the node's `resultSchema` + redact at this boundary (reuse runStep/append)
20
- * → record the result as the next `lastResult`
21
- * → repeat until `decision.type === 'complete'` → finish the run.
22
- *
23
- * RECOVERY: DBOS replays the workflow body; `core.step` is deterministic and every effect is a durable
24
- * memoized DBOS step, so a replay re-derives the identical Decision sequence and consumes the recorded
25
- * step results — no live race, no duplicate effects (deterministic ids + ROW_CONFLICT no-op).
26
- *
27
- * C1 architecture: the body is an extracted DBOS-free builder (`makeDataDrivenTask`) registered via the
28
- * engine seam, so tests exercise the SAME production logic directly with a plain step fn.
29
- */
30
- import { step as coreStep, initialState, validateTemplate, InterpretError, } from '../pipeline-core/index.js';
1
+ import { step as coreStep, initialState, validateTemplate, InterpretError, selectJoinWinner, reduceJoinVerdict, } from '../pipeline-core/index.js';
31
2
  import { runnerNeedsLivePreflight, runnerUsesRealIntegrator } from './route-contract.js';
32
3
  import { redactEventPayload } from '../run/append-event.js';
33
4
  import { redactSecrets } from '../control-plane/inbox.js';
5
+ import { fnv1a64Hex } from '../control-plane/steps.js';
6
+ import { normalizeIssueRef } from '../run/issue-ref.js';
7
+ import { RUNNER_IDLE_TIMEOUT_KIND, RUNNER_WALL_CLOCK_LIMIT_KIND, } from '../worker/process-executor.js';
34
8
  export const RUN_PROGRESS_EVENT_KEY = 'run-progress';
35
- const MAX_STEPS = 1_000; // a VALID template terminates; guards a data/loop authoring mistake at runtime.
36
- /**
37
- * Reserved engine error codes (matched only by a node's `catch`, §3/§6).
38
- *
39
- * A built-in script (the integrator) has TWO distinct failure modes the routing data discriminates:
40
- * - `revo.ScriptBlocked` — the script needs a human (nothing-to-integrate, ambiguous PRs, a refused
41
- * pinned identity, a non-JSON `pr view`). NOT a crash; a `catch` routes it to a `blocked` terminal,
42
- * and the adapter surfaces the human-readable lesson on a `pipeline_blocked` event (parity with the
43
- * old engine's `blockPipeline({ reason:'integrate' })`).
44
- * - `revo.ScriptFailed` — the script THREW (a gh/push error). A `catch` routes it to a `failed`
45
- * terminal (parity with the old engine's top-level catch → failRun).
46
- */
9
+ const MAX_STEPS = 1_000;
10
+ const DEFAULT_RUNNER_TRANSIENT_MAX_ATTEMPTS = 2;
11
+ const DEFAULT_RUNNER_TRANSIENT_RETRY_BACKOFF_MS = 2_000;
12
+ function readPositiveIntegerEnv(env, key, fallback) {
13
+ const raw = env[key];
14
+ if (raw === undefined)
15
+ return fallback;
16
+ if (!/^\d+$/.test(raw))
17
+ throw new Error(`${key} must be a positive integer`);
18
+ const parsed = Number(raw);
19
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
20
+ throw new Error(`${key} must be a positive integer`);
21
+ return parsed;
22
+ }
23
+ function readNonNegativeIntegerEnv(env, key, fallback) {
24
+ const raw = env[key];
25
+ if (raw === undefined)
26
+ return fallback;
27
+ if (!/^\d+$/.test(raw))
28
+ throw new Error(`${key} must be a non-negative integer`);
29
+ const parsed = Number(raw);
30
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
31
+ throw new Error(`${key} must be a non-negative integer`);
32
+ return parsed;
33
+ }
34
+ export function resolveRunnerTransientRetryPolicy(env = process.env) {
35
+ return {
36
+ maxAttempts: readPositiveIntegerEnv(env, 'REVO_RUNNER_TRANSIENT_MAX_ATTEMPTS', DEFAULT_RUNNER_TRANSIENT_MAX_ATTEMPTS),
37
+ backoffMs: readNonNegativeIntegerEnv(env, 'REVO_RUNNER_TRANSIENT_RETRY_BACKOFF_MS', DEFAULT_RUNNER_TRANSIENT_RETRY_BACKOFF_MS),
38
+ };
39
+ }
40
+ function physicalAttemptFor(runId, stepKey, attemptNo) {
41
+ const attemptKey = `${runId}|${stepKey}|${attemptNo}`;
42
+ return {
43
+ attemptNo,
44
+ attemptId: `attempt_${fnv1a64Hex(attemptKey)}`,
45
+ };
46
+ }
47
+ function stepInputForAttempt(nodeId, inputs, attempt) {
48
+ return {
49
+ nodeId,
50
+ attempt: { attemptNo: attempt.attemptNo, attemptId: attempt.attemptId },
51
+ ...(Object.keys(inputs).length > 0 ? { inputs } : {}),
52
+ };
53
+ }
54
+ function optionalTiming(timing) {
55
+ return timing === undefined ? {} : { timing };
56
+ }
57
+ function optionalFailureKind(failureKind) {
58
+ return failureKind === undefined ? {} : { failureKind };
59
+ }
47
60
  const REVO_SCRIPT_FAILED = 'revo.ScriptFailed';
48
61
  const REVO_SCRIPT_BLOCKED = 'revo.ScriptBlocked';
49
62
  const REVO_RESULT_INVALID = 'revo.ResultInvalid';
50
63
  const REVO_INPUT_MISSING = 'revo.InputMissing';
51
- /** Per-node execution stepKey: the bare nodeId on the first entry (stable ids for existing tests), an
52
- * ordinal-suffixed key on loop re-entries so attempts/events/outputs are distinct per iteration (0016
53
- * §4.1 — fixes the latent 0015 stepKey-reuse collision). */
64
+ function invalidRoleResult(reason, physicalAttempt) {
65
+ return {
66
+ failed: true,
67
+ errorCode: REVO_RESULT_INVALID,
68
+ reason,
69
+ attemptId: physicalAttempt.attemptId,
70
+ attemptsMade: physicalAttempt.attemptNo,
71
+ };
72
+ }
73
+ function runnerBlockReason(transient) {
74
+ if (transient.failureKind)
75
+ return transient.failureKind;
76
+ return `runner-transient-failure:${transient.transientKind}`;
77
+ }
78
+ function runnerBlockLesson(transient) {
79
+ const safe = String(redactEventPayload(transient.reason));
80
+ if (transient.failureKind)
81
+ return `${transient.failureKind}: ${safe || 'runner failed'}`;
82
+ return `runner-transient-failure (${transient.transientKind}): ${safe || 'runner failed'}`;
83
+ }
84
+ function pipelineBlockedPayload(reason, lesson, retry) {
85
+ if (retry === undefined)
86
+ return { reason, lesson };
87
+ return { ...retry };
88
+ }
54
89
  function stepKeyFor(nodeId, ordinal) {
55
90
  return ordinal <= 1 ? nodeId : `${nodeId}#${ordinal}`;
56
91
  }
57
- /** Next per-(run,node) execution ordinal (1-based). Deterministic on DBOS replay: the runBody loop
58
- * re-runs identically (coreStep is pure, effects are memoized), so the accumulator is rebuilt 1:1. */
59
92
  function nextOrdinal(byNode, nodeId) {
60
93
  const n = (byNode.get(nodeId) ?? 0) + 1;
61
94
  byNode.set(nodeId, n);
@@ -64,7 +97,120 @@ function nextOrdinal(byNode, nodeId) {
64
97
  function isRecord(value) {
65
98
  return value !== null && typeof value === 'object' && !Array.isArray(value);
66
99
  }
67
- /** Extract the DOMAIN verdict from the only supported source: top-level AttemptResult.verdict. */
100
+ function producedChangeArtifact(value) {
101
+ if (!isRecord(value))
102
+ return undefined;
103
+ const candidate = isRecord(value.change) ? value.change : value;
104
+ const branch = candidate.branch;
105
+ const headSha = candidate.headSha;
106
+ if (typeof branch !== 'string' || branch.trim().length === 0)
107
+ return undefined;
108
+ if (typeof headSha !== 'string' || headSha.trim().length === 0)
109
+ return undefined;
110
+ const issueRef = normalizeArtifactIssueRef(candidate.issueRef);
111
+ return {
112
+ branch,
113
+ headSha,
114
+ ...(issueRef ? { issueRef } : {}),
115
+ ...(typeof candidate.worktreePath === 'string' && candidate.worktreePath.trim() ? { worktreePath: candidate.worktreePath } : {}),
116
+ ...(typeof candidate.artifactRef === 'string' && candidate.artifactRef.trim() ? { artifactRef: candidate.artifactRef } : {}),
117
+ ...(typeof candidate.prNumber === 'number' && Number.isSafeInteger(candidate.prNumber) ? { prNumber: candidate.prNumber } : {}),
118
+ };
119
+ }
120
+ function normalizeArtifactIssueRef(value) {
121
+ try {
122
+ return normalizeIssueRef(value, 'change.issueRef');
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ function changeWithRunIssueRef(change, issueRef) {
129
+ const normalized = { ...change };
130
+ if (issueRef) {
131
+ normalized.issueRef = issueRef;
132
+ }
133
+ else {
134
+ delete normalized.issueRef;
135
+ }
136
+ return normalized;
137
+ }
138
+ function producedChangeFromInputs(inputs) {
139
+ for (const key of ['reviewChange', 'ciChange', 'reworkChange', 'developerChange', 'change']) {
140
+ const artifact = producedChangeArtifact(inputs[key]);
141
+ if (artifact)
142
+ return artifact;
143
+ }
144
+ return undefined;
145
+ }
146
+ function mergeReadinessFromInputs(inputs) {
147
+ const value = inputs.mergeReadiness;
148
+ if (!isRecord(value))
149
+ return undefined;
150
+ const headSha = value.headSha;
151
+ if (typeof headSha !== 'string' || headSha.trim().length === 0)
152
+ return undefined;
153
+ return { headSha };
154
+ }
155
+ function attachProducedChange(output, change) {
156
+ if (isRecord(output))
157
+ return { ...output, change };
158
+ return { summary: output, change };
159
+ }
160
+ function nodeProducesChange(node) {
161
+ return (node.kind === 'agent' || node.kind === 'script') &&
162
+ node.produces?.name === 'change' &&
163
+ node.resultSchema === 'schema:change';
164
+ }
165
+ function runnerProducesWorktreeChanges(runnerId) {
166
+ return runnerId === 'claude-code' || runnerId === 'codex';
167
+ }
168
+ function artifactRefFromResult(result) {
169
+ const artifacts = result.artifacts;
170
+ const processArtifact = isRecord(artifacts) && isRecord(artifacts.process) ? artifacts.process : artifacts;
171
+ if (!isRecord(processArtifact))
172
+ return undefined;
173
+ return typeof processArtifact.ref === 'string' ? processArtifact.ref : undefined;
174
+ }
175
+ function publicTimeoutFailureKind(value) {
176
+ return value === RUNNER_IDLE_TIMEOUT_KIND || value === RUNNER_WALL_CLOCK_LIMIT_KIND
177
+ ? value
178
+ : undefined;
179
+ }
180
+ function transientRunnerFailure(result) {
181
+ const output = result.output;
182
+ if (!isRecord(output) || output.error !== 'runner_failed')
183
+ return undefined;
184
+ const reason = typeof output.reason === 'string' ? output.reason : '';
185
+ const failureKind = publicTimeoutFailureKind(output.failureKind);
186
+ const legacyKind = transientKind(reason);
187
+ const retryableCandidate = output.retryableCandidate !== false;
188
+ return {
189
+ reason,
190
+ transientKind: failureKind ? 'timeout' : legacyKind,
191
+ retryableCandidate,
192
+ retryable: retryableCandidate && (failureKind !== undefined || legacyKind !== 'unknown'),
193
+ ...optionalFailureKind(failureKind),
194
+ ...optionalTiming(output.timing),
195
+ };
196
+ }
197
+ function transientKind(reason) {
198
+ if (/exceeded\s*\d+\s*ms|timed?\s*out|\btimeout\b/i.test(reason))
199
+ return 'timeout';
200
+ if (/\b429\b|rate.?limit|session limit/i.test(reason))
201
+ return 'rate_limit';
202
+ if (isLegacyRetryableCrashReason(reason))
203
+ return 'crash';
204
+ return 'unknown';
205
+ }
206
+ function isLegacyRetryableCrashReason(reason) {
207
+ if (/\b(auth|credential|permission|forbidden|denied|unauthori[sz]ed|config|schema|malformed|parseable|invalid|RUNNER_NOT_IMPLEMENTED|not wired|unknown runner|requires an OpenAI|ENOENT|not found)\b/i
208
+ .test(reason)) {
209
+ return false;
210
+ }
211
+ return /\b(signal|crash(?:ed)?|killed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|ENOMEM|EAGAIN)\b/i
212
+ .test(reason);
213
+ }
68
214
  function domainVerdictOf(result) {
69
215
  if (typeof result.verdict === 'string' && result.verdict.trim().length > 0) {
70
216
  return result.verdict.trim().toLowerCase();
@@ -83,12 +229,6 @@ function resultVerdictProblem(template, node, result) {
83
229
  }
84
230
  return undefined;
85
231
  }
86
- /**
87
- * Validate a recorded result against a node's `resultSchema` at the ADAPTER boundary (§10). MVP
88
- * contract (resolves §14 Q3 — resultSchema is a DATA handle, validated structurally here): a declared
89
- * `resultSchema` requires a non-empty object/string output. Token redaction already happens inside
90
- * `runStep`/`appendEvent`; this guards a malformed effect result → `revo.ResultInvalid` (§6 precedence).
91
- */
92
232
  function resultSatisfiesSchema(node, result) {
93
233
  if (!('resultSchema' in node) || !node.resultSchema)
94
234
  return true;
@@ -109,28 +249,11 @@ function progressCursor(state, lastResult) {
109
249
  ...(lastResult ? { lastResult } : {}),
110
250
  };
111
251
  }
112
- /**
113
- * Map an approve/reject human decision onto a DOMAIN verdict the template's gate `outcomes` can route.
114
- * The core never sees approve/reject — it routes on a domain label (§8). Approve → the first declared
115
- * outcome (the "proceed" label by template convention, e.g. `approved`); reject → fail-closed to a
116
- * NON-first outcome if one exists (e.g. `changes_requested`), else undefined so the gate's `default`
117
- * (typically a `blocked` terminal) fires. This keeps gate semantics 100% in the routing data (§6/§8).
118
- */
119
252
  function gateVerdict(decision, outcomes) {
120
253
  if (decision.decision === 'approve')
121
254
  return outcomes[0];
122
- // reject: prefer a declared "rework/changes" outcome; else let the default branch route (blocked).
123
255
  return outcomes.length > 1 ? outcomes.at(-1) : undefined;
124
256
  }
125
- /**
126
- * Stable gate topic from a node's reason. The topic is the DBOS recv channel AND part of the gate
127
- * inbox id (`runId|topic`), so DISTINCT gates in one pipeline MUST map to DISTINCT topics — otherwise
128
- * a second gate's `recv` collides with the first's already-consumed message and the run hangs (plan
129
- * 0018: the `review-question` gate would otherwise reuse the `plan` topic of the plan gate).
130
- * merge-review → 'merge'
131
- * review-question→ 'question'
132
- * (anything else)→ 'plan' (the plan-review gate)
133
- */
134
257
  function gateTopicFor(reason) {
135
258
  if (/merge/i.test(reason))
136
259
  return 'merge';
@@ -138,15 +261,8 @@ function gateTopicFor(reason) {
138
261
  return 'question';
139
262
  return 'plan';
140
263
  }
141
- // D3 — enrich the gate inbox row with the artifact under review + the reviewer verdict, inline, so an
142
- // approver decides without digging the agent log. `pushInbox` has no size cap (unlike run-outputs'
143
- // PAYLOAD_MAX), so the artifact is budgeted HERE: over-budget → a head preview + a payload_ref locator,
144
- // never the full payload. The inbox id is keyed by runId|gateKey only, so a larger context does not
145
- // change it → pushInbox stays idempotent on replay. The payload is also secret/token redacted at this
146
- // build site (pushInbox only masks secret-NAMED keys, not token SHAPES in free-text) — see gateArtifactView.
147
264
  export const GATE_ARTIFACT_MAX = 16_000;
148
265
  export const GATE_PREVIEW_CHARS = 4_000;
149
- /** Latest (or pinned-ordinal) output row for a gate ref, or undefined if the producer has not run. */
150
266
  function resolveGateRow(ref, outputsByNode) {
151
267
  if (!ref)
152
268
  return undefined;
@@ -155,20 +271,12 @@ function resolveGateRow(ref, outputsByNode) {
155
271
  return undefined;
156
272
  if (typeof ref.iteration === 'number')
157
273
  return produced.find((o) => o.ordinal === ref.iteration);
158
- return produced[produced.length - 1]; // 'latest' (and 'all' → the most recent for an inline view)
159
- }
160
- /**
161
- * Inline artifact view with the 16KB budget. Secrets + token shapes are scrubbed BEFORE inlining or
162
- * previewing — mirroring run-outputs.ts (the sibling persist boundary): pushInbox only masks
163
- * secret-NAMED keys, so a token shape (`ghp_…`) in a free-text field (e.g. pollPr's prFeedback) would
164
- * otherwise persist verbatim. Over-budget → a head preview (of the redacted serialization) + an
165
- * `attempt:` locator (the full artifact is recoverable from that attempt's agent log).
166
- */
274
+ return produced[produced.length - 1];
275
+ }
167
276
  function gateArtifactView(row, as) {
168
277
  const base = { nodeId: row.nodeId, name: as ?? row.name, schemaRef: row.schemaRef };
169
278
  const safe = redactEventPayload(redactSecrets(row.payload) ?? null);
170
279
  const serialized = JSON.stringify(safe ?? null);
171
- // Budget on BYTES (not UTF-16 length) so a multi-byte payload can't slip past the cap.
172
280
  if (Buffer.byteLength(serialized, 'utf8') <= GATE_ARTIFACT_MAX)
173
281
  return { ...base, payload: safe };
174
282
  return {
@@ -178,12 +286,6 @@ function gateArtifactView(row, as) {
178
286
  payloadRef: `attempt:${row.attemptId ?? ''}`,
179
287
  };
180
288
  }
181
- /**
182
- * Build the enriched gate inbox summary from the workflow-local outputs (replay-safe — rebuilt
183
- * identically, 0016 §6). `verdictFrom` resolves a node's verdict output; when it is NOT specified, the
184
- * verdict defaults to the routing verdict that opened the gate (`lastVerdict`). Routing is unaffected —
185
- * purely informational.
186
- */
187
289
  export function buildGateSummary(decision, outputsByNode, lastVerdict) {
188
290
  const summary = { nodeId: decision.nodeId, outcomes: decision.outcomes };
189
291
  const artRow = resolveGateRow(decision.gatedArtifact, outputsByNode);
@@ -192,22 +294,12 @@ export function buildGateSummary(decision, outputsByNode, lastVerdict) {
192
294
  const verdictRow = resolveGateRow(decision.verdictFrom, outputsByNode);
193
295
  if (verdictRow)
194
296
  summary.reviewerVerdict = gateArtifactView(verdictRow);
195
- // Only fall back to the routing verdict when no verdictFrom was REQUESTED — a specified-but-unresolved
196
- // verdictFrom must not silently present the routing verdict as if it came from that source.
197
297
  else if (!decision.verdictFrom && lastVerdict)
198
298
  summary.reviewerVerdict = { verdict: lastVerdict };
199
299
  return summary;
200
300
  }
201
- /**
202
- * makeDataDrivenTask — DBOS-free factory for the dataDrivenTask async function (C1).
203
- *
204
- * Receives the (DBOS-wrapped in prod, plain in tests) `runStepFn` so the body is IDENTICAL across
205
- * production and tests. Returns a plain async function with the workflow signature.
206
- */
207
301
  export function makeDataDrivenTask(runStepFn, deps) {
208
- const { appendEvent, appendRunOutput, awaitHuman, completeRun, failRun, blockRun, loadRunTaskContext, integrateFn, runStub, confirmMergeFn, runConfirmStub, pollPrFn, runPollStub, respondThreadsFn, runRespondStub, preflightFn, createWorktreeFn, releaseWorktreeFn } = deps;
209
- /** Resolve a node's `consumes` from the workflow-local output accumulator — NOT live Revisium reads
210
- * (0016 §6 / consensus M4: a live read on replay can see rows written past the replay point). */
302
+ const { appendEvent, appendRunOutput, awaitHuman, completeRun, failRun, blockRun, loadRunTaskContext, integrateFn, runStub, confirmMergeFn, runConfirmStub, pollPrFn, runPollStub, respondThreadsFn, runRespondStub, captureChangeFn, preflightFn, createWorktreeFn, releaseWorktreeFn } = deps;
211
303
  function resolveConsumes(node, outputsByNode) {
212
304
  const refs = 'consumes' in node ? (node.consumes ?? []) : [];
213
305
  const inputs = {};
@@ -238,9 +330,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
238
330
  }
239
331
  return { inputs };
240
332
  }
241
- /** Record a node's produced output to the workflow-local accumulator + Revisium (when `produces`). */
242
- // (gate-summary helpers live at module scope below — they are pure over outputsByNode.)
243
- async function recordOutput(runId, node, ordinal, output, outputsByNode) {
333
+ async function recordOutput(runId, node, ordinal, attemptId, output, outputsByNode) {
244
334
  if (!('produces' in node) || !node.produces)
245
335
  return;
246
336
  const row = {
@@ -250,7 +340,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
250
340
  name: node.produces.name,
251
341
  schemaRef: ('resultSchema' in node && node.resultSchema) || '',
252
342
  payload: output,
253
- attemptId: stepKeyFor(node.id, ordinal),
343
+ attemptId,
254
344
  };
255
345
  const list = outputsByNode.get(node.id) ?? [];
256
346
  list.push(row);
@@ -262,9 +352,6 @@ export function makeDataDrivenTask(runStepFn, deps) {
262
352
  return await runBody(runId, opts);
263
353
  }
264
354
  catch (err) {
265
- // TERMINAL failure: mark the Revisium run-row `failed` (idempotent, event-first) before
266
- // re-throwing so DBOS still records the workflow ERROR (progress truth) and the run-row stops
267
- // lying. The run's terminal-failure surfacing contract (0008 #2).
268
355
  const reason = err instanceof Error ? err.message : String(err);
269
356
  try {
270
357
  await failRun(runId, reason);
@@ -277,64 +364,25 @@ export function makeDataDrivenTask(runStepFn, deps) {
277
364
  };
278
365
  async function runBody(runId, opts) {
279
366
  const { route, template } = opts;
280
- // Defense-in-depth: the pinned template is validated at the boundary (§9 — pipeline-core is the
281
- // authoritative validator even though selection already validated it). A broken pin fails the run.
282
367
  const diagnostics = validateTemplate(template).filter((d) => d.severity === 'error');
283
368
  if (diagnostics.length > 0) {
284
369
  throw new Error(`PINNED_TEMPLATE_INVALID: ${template.pipelineId} — ${diagnostics.map((d) => d.code).join(', ')}`);
285
370
  }
286
- const { taskId, title, base } = await loadRunTaskContext(runId);
287
- // B5/B7 — live preflight: one memoized DBOS step, evaluated exactly once BEFORE the graph runs.
288
- // Skipped entirely when every selected binding resolves to a stub/script runner (mirrors the old
289
- // engine's gate). A `needsHuman` preflight blocks the run (clean/base invariant unmet) and surfaces
290
- // the lesson — the run never enters the graph. This is an engine-level guard (infrastructure), not a
291
- // template node: it is cross-cutting and identical for every pinned pipeline (zero role-ids/shapes).
371
+ const { taskId, title, base, issueRef } = await loadRunTaskContext(runId);
292
372
  const live = route.roleBindings.some((b) => runnerNeedsLivePreflight(b.resolvedRunnerId));
293
373
  if (live) {
294
- // Preflight runs against the BASE checkout (resolveTaskCwd), BEFORE the worktree exists — its
295
- // fetch + clean/freshness checks protect the user's base repo. Ordering is load-bearing.
296
374
  const pf = await preflightFn(taskId, base);
297
375
  if ('needsHuman' in pf) {
298
376
  return await blockWithLesson(runId, taskId, 'preflight', pf.lesson, 0);
299
377
  }
300
378
  }
301
- // Per-run worktree (plan 0017): create AFTER a passing preflight and BEFORE any live effect, so all
302
- // repo-touching steps (developer/rework + integrator) resolve to the isolated worktree (keyed by
303
- // runId) — never the shared base checkout. Skipped for non-live runs. The `finally` releases it at a
304
- // SUCCEEDED terminal (the PR is merged via confirmMerge → branch is in base → worktree disposable) and
305
- // on a throw (failure); it KEEPS the worktree on a `blocked` terminal (confirmMerge blocks when the PR
306
- // isn't merged yet — the tree must survive for rework / a manual merge; reclaimed by cleanup_worktree
307
- // + the host-start sweep). It does NOT run while parked at a gate (awaitHuman suspends the live
308
- // workflow via `recv`, so runBody never returns there). Create is INSIDE the try so the release also
309
- // cleans up a create that partially built the worktree before throwing (codex/CodeRabbit).
310
- let result;
311
- try {
312
- if (live) {
313
- await createWorktreeFn(runId, taskId, title, base);
314
- }
315
- result = await runGraph(runId, opts, taskId, title, base);
316
- return result;
317
- }
318
- finally {
319
- if (live && result?.status !== 'blocked') {
320
- try {
321
- await releaseWorktreeFn(runId, taskId);
322
- }
323
- catch (releaseErr) {
324
- console.warn(`[data-driven] worktree release for ${runId} failed (orphan; pruned later): ${String(releaseErr)}`);
325
- }
326
- }
379
+ if (live) {
380
+ await createWorktreeFn(runId, taskId, title, base, issueRef);
327
381
  }
382
+ return runGraph(runId, opts, taskId, title, base, issueRef, live);
328
383
  }
329
- /** The pipeline-core interpretation loop, extracted so the worktree `finally` in runBody wraps it. */
330
- async function runGraph(runId, opts, taskId, title, base) {
331
- const { route, template } = opts;
332
- // Capability resolution map (GENERIC — no role-ids in the engine): roleRef/scriptRef → route binding.
333
- // The route's bindings are authoritative for runner dispatch (and durable on recovery via the DBOS
334
- // workflow input). A `role:<id>` handle resolves to the binding whose roleId matches `<id>`. A
335
- // `script:<id>` handle likewise resolves by roleId; additionally the canonical `script:integrator`
336
- // handle resolves to whichever binding's RESOLVED runner mechanically performs the merge (runner-
337
- // wins, D7) so the integrator script dispatches real-vs-stub exactly like the hardcoded path.
384
+ async function runGraph(runId, opts, taskId, title, base, issueRef, live) {
385
+ const { route, template, runnerRetryPolicy } = opts;
338
386
  const bindingByRef = new Map();
339
387
  for (const binding of route.roleBindings) {
340
388
  bindingByRef.set(`role:${binding.roleId}`, binding);
@@ -350,9 +398,6 @@ export function makeDataDrivenTask(runStepFn, deps) {
350
398
  let lastVerdict = '';
351
399
  let lastFailureReason = '';
352
400
  let stepCount = 0;
353
- // Workflow-local dataflow state (0016 §4.1/§6): per-node execution ordinals + produced outputs.
354
- // Both are rebuilt deterministically on DBOS replay (the loop re-runs identically); the adapter
355
- // hydrates consumers from `outputsByNode`, never from a live Revisium read.
356
401
  const effectOrdinalByNode = new Map();
357
402
  const outputsByNode = new Map();
358
403
  for (let i = 0; i < MAX_STEPS; i++) {
@@ -363,25 +408,111 @@ export function makeDataDrivenTask(runStepFn, deps) {
363
408
  return await finish(runId, decision.status, lastVerdict, stepCount, lastFailureReason);
364
409
  }
365
410
  const eff = await applyDecision(decision, {
366
- runId, template, bindingByRef, executionProfile, taskId, title, base,
367
- effectOrdinalByNode, outputsByNode,
368
- // The verdict from the PRIOR effect (the reviewer/poller that routed into this node). At an
369
- // awaitGate this is the routing verdict that opened the gate → D3's default reviewerVerdict.
411
+ runId, template, state, bindingByRef, executionProfile, taskId, title, base, issueRef,
412
+ effectOrdinalByNode, outputsByNode, runnerRetryPolicy,
413
+ live,
370
414
  lastVerdict,
371
415
  });
416
+ stepCount += eff.stepDelta;
417
+ if (eff.terminal) {
418
+ return await blockWithLesson(runId, taskId, eff.terminal.reason, eff.terminal.lesson, stepCount, eff.terminal.retry);
419
+ }
420
+ if (eff.stateOverride) {
421
+ state = eff.stateOverride;
422
+ await deps.setProgress?.(runId, progressCursor(state, eff.lastResult));
423
+ }
372
424
  lastResult = eff.lastResult;
373
425
  if (eff.lastVerdict !== undefined)
374
426
  lastVerdict = eff.lastVerdict;
375
427
  lastFailureReason = eff.failureReason ?? '';
376
- stepCount += eff.stepDelta;
377
428
  }
378
429
  throw new InterpretError(`data-driven ${template.pipelineId} did not terminate within ${MAX_STEPS} steps (template loop bug)`);
379
430
  }
380
- /**
381
- * Execute ONE non-terminal Decision as a durable effect and return the next `lastResult`/`lastVerdict`
382
- * + the step-count delta. Extracted from the `runBody` loop so each is small + independently testable
383
- * (the loop just threads the result; this is the per-kind dispatch).
384
- */
431
+ function branchTemplateForJoin(template, joinId) {
432
+ const join = resolveNode(template, joinId);
433
+ if (join.kind !== 'join')
434
+ throw new InterpretError(`fork target ${joinId} is not a join (${join.kind})`);
435
+ return {
436
+ ...template,
437
+ nodes: {
438
+ ...template.nodes,
439
+ [joinId]: { id: joinId, kind: 'terminal', status: 'succeeded' },
440
+ },
441
+ };
442
+ }
443
+ async function executeForkBranches(decision, ctx) {
444
+ const branchTemplate = branchTemplateForJoin(ctx.template, decision.joinId);
445
+ const results = await Promise.all(decision.branches.map((branch, idx) => executeForkBranch(branchTemplate, decision, branch, idx + 1, ctx)));
446
+ const stepDelta = results.reduce((sum, result) => sum + result.stepDelta, 0);
447
+ const terminal = results.find((result) => result.terminal)?.terminal;
448
+ if (terminal)
449
+ return { lastResult: undefined, terminal, stepDelta };
450
+ const arrivals = results.flatMap((result) => result.arrival ? [result.arrival] : []);
451
+ if (arrivals.length !== decision.branches.length) {
452
+ throw new InterpretError(`fork ${decision.nodeId} expected ${decision.branches.length} branch arrivals, got ${arrivals.length}`);
453
+ }
454
+ const lastResult = { joinArrivals: arrivals };
455
+ const winner = selectJoinWinner(decision.mode, arrivals, decision.joinId);
456
+ const join = resolveNode(ctx.template, decision.joinId);
457
+ if (join.kind !== 'join')
458
+ throw new InterpretError(`fork target ${decision.joinId} is not a join (${join.kind})`);
459
+ const verdict = reduceJoinVerdict(join, arrivals, winner);
460
+ return {
461
+ lastResult,
462
+ ...(verdict !== undefined ? { lastVerdict: verdict } : {}),
463
+ stateOverride: {
464
+ ...ctx.state,
465
+ activeNodeIds: new Set([decision.joinId]),
466
+ status: 'running',
467
+ lastResult,
468
+ },
469
+ stepDelta,
470
+ };
471
+ }
472
+ async function executeForkBranch(branchTemplate, decision, branch, seq, ctx) {
473
+ let state = {
474
+ ...ctx.state,
475
+ activeNodeIds: new Set([branch.entry]),
476
+ scopedCounters: { ...ctx.state.scopedCounters },
477
+ status: 'running',
478
+ lastResult: undefined,
479
+ };
480
+ let lastResult;
481
+ let lastVerdict = '';
482
+ let stepDelta = 0;
483
+ for (let i = 0; i < MAX_STEPS; i++) {
484
+ const next = coreStep(branchTemplate, state, lastResult);
485
+ state = next.state;
486
+ if (next.decision.type === 'complete') {
487
+ if (next.decision.status !== 'succeeded') {
488
+ throw new InterpretError(`fork ${decision.nodeId} branch ${branch.id} completed ${next.decision.status} before join ${decision.joinId}`);
489
+ }
490
+ return {
491
+ arrival: {
492
+ branchId: branch.id,
493
+ seq,
494
+ ...(lastVerdict ? { verdict: lastVerdict } : {}),
495
+ },
496
+ stepDelta,
497
+ };
498
+ }
499
+ const eff = await applyDecision(next.decision, {
500
+ ...ctx,
501
+ template: branchTemplate,
502
+ state,
503
+ lastVerdict,
504
+ });
505
+ stepDelta += eff.stepDelta;
506
+ if (eff.terminal)
507
+ return { terminal: eff.terminal, stepDelta };
508
+ if (eff.stateOverride)
509
+ state = eff.stateOverride;
510
+ lastResult = eff.lastResult;
511
+ if (eff.lastVerdict !== undefined)
512
+ lastVerdict = eff.lastVerdict;
513
+ }
514
+ throw new InterpretError(`fork ${decision.nodeId} branch ${branch.id} did not reach join ${decision.joinId} within ${MAX_STEPS} steps`);
515
+ }
385
516
  async function applyDecision(decision, ctx) {
386
517
  const { runId, template, bindingByRef, taskId, title, base } = ctx;
387
518
  switch (decision.type) {
@@ -391,9 +522,6 @@ export function makeDataDrivenTask(runStepFn, deps) {
391
522
  const stepKey = stepKeyFor(node.id, ordinal);
392
523
  const resolved = resolveConsumes(node, ctx.outputsByNode);
393
524
  if ('missing' in resolved) {
394
- // A required upstream output is absent → fail-loud as a WIRING fault (0016 §6 / consensus M3):
395
- // a dedicated step_failed names the missing (node, as), distinct from a domain `blocker`. The
396
- // node's default onFailure='abort' then routes to a failed terminal (the run fails loud).
397
525
  await appendEvent({
398
526
  runId, taskId, stepId: '', stepKey, type: 'step_failed',
399
527
  payload: { nodeId: node.id, error: `${REVO_INPUT_MISSING}: required input ${resolved.missing} was not produced` },
@@ -401,26 +529,37 @@ export function makeDataDrivenTask(runStepFn, deps) {
401
529
  return { lastResult: { outcome: 'failed', errorCode: REVO_INPUT_MISSING }, lastVerdict: 'failed', stepDelta: 1 };
402
530
  }
403
531
  const result = await invokeRole(runId, decision, node, ctx, resolved.inputs, stepKey);
532
+ if ('blocked' in result) {
533
+ return {
534
+ lastResult: undefined,
535
+ terminal: {
536
+ status: 'blocked',
537
+ reason: result.reason,
538
+ lesson: result.lesson,
539
+ ...(result.retry ? { retry: result.retry } : {}),
540
+ },
541
+ stepDelta: result.attemptsMade,
542
+ };
543
+ }
404
544
  if (result.failed) {
405
545
  await appendEvent({
406
546
  runId, taskId, stepId: '', stepKey, type: 'step_failed',
547
+ idempotencyKey: result.attemptId,
407
548
  payload: { nodeId: node.id, error: result.errorCode },
408
549
  });
409
- return { lastResult: { outcome: 'failed', errorCode: result.errorCode }, lastVerdict: 'failed', failureReason: result.reason, stepDelta: 1 };
550
+ return { lastResult: { outcome: 'failed', errorCode: result.errorCode }, lastVerdict: 'failed', failureReason: result.reason, stepDelta: result.attemptsMade };
410
551
  }
411
- await recordOutput(runId, node, ordinal, result.output, ctx.outputsByNode);
552
+ await recordOutput(runId, node, ordinal, result.attemptId, result.output, ctx.outputsByNode);
412
553
  const verdict = result.verdict;
413
554
  return {
414
555
  lastResult: { outcome: 'succeeded', ...(verdict ? { verdict } : {}) },
415
556
  ...(verdict ? { lastVerdict: verdict } : {}),
416
- stepDelta: 1,
557
+ stepDelta: result.attemptsMade,
417
558
  };
418
559
  }
419
560
  case 'invokeScript': {
420
561
  const node = resolveNode(template, decision.nodeId);
421
562
  const ordinal = nextOrdinal(ctx.effectOrdinalByNode, node.id);
422
- // A script node may `consumes` upstream data (plan 0018: respondThreads ← triage). Hydrate it
423
- // from the workflow-local accumulator (same seam as agents), fail-loud on a missing required input.
424
563
  const resolved = resolveConsumes(node, ctx.outputsByNode);
425
564
  if ('missing' in resolved) {
426
565
  await appendEvent({
@@ -429,35 +568,25 @@ export function makeDataDrivenTask(runStepFn, deps) {
429
568
  });
430
569
  return { lastResult: { outcome: 'failed', errorCode: REVO_INPUT_MISSING }, lastVerdict: 'failed', stepDelta: 1 };
431
570
  }
432
- const scriptResult = await invokeScript(runId, decision, { taskId, title, base }, bindingByRef, stepKeyFor(node.id, ordinal), resolved.inputs);
433
- // A blocked script (needsHuman) routes via revo.ScriptBlocked → a `blocked` terminal; a thrown
434
- // script routes via revo.ScriptFailed → a `failed` terminal (§6 catch). The lesson-bearing
435
- // pipeline_blocked is emitted inside invokeScript for the block path (parity with the old engine).
571
+ const scriptResult = await invokeScript(runId, decision, { taskId, title, base, issueRef: ctx.issueRef }, bindingByRef, stepKeyFor(node.id, ordinal), resolved.inputs);
436
572
  if (scriptResult.outcome === 'blocked') {
437
573
  return { lastResult: { outcome: 'failed', errorCode: REVO_SCRIPT_BLOCKED }, lastVerdict: 'blocked', stepDelta: 1 };
438
574
  }
439
575
  if (scriptResult.outcome === 'failed') {
440
576
  return { lastResult: { outcome: 'failed', errorCode: REVO_SCRIPT_FAILED }, lastVerdict: 'failed', stepDelta: 1 };
441
577
  }
442
- await recordOutput(runId, node, ordinal, scriptResult.pointer, ctx.outputsByNode);
443
- // A classifying script (pollPr, plan 0018) surfaces a DOMAIN verdict so the next `choice` can
444
- // route on it (§8). Scripts with a single fixed `next` (integrator/confirmMerge) carry none.
578
+ await recordOutput(runId, node, ordinal, stepKeyFor(node.id, ordinal), scriptResult.pointer, ctx.outputsByNode);
445
579
  const sv = scriptResult.verdict;
446
580
  return { lastResult: { outcome: 'succeeded', ...(sv ? { verdict: sv } : {}) }, ...(sv ? { lastVerdict: sv } : {}), stepDelta: 1 };
447
581
  }
448
582
  case 'awaitGate': {
449
583
  const topic = gateTopicFor(decision.reason);
450
- // Per-entry gate key (nodeId#ordinal) so a re-entered gate (e.g. a question gate looped in the
451
- // review phase) gets a DISTINCT inbox row instead of colliding on `runId|topic` (§3.2 audit).
452
584
  const ordinal = nextOrdinal(ctx.effectOrdinalByNode, decision.nodeId);
453
585
  const human = await awaitHuman(runId, topic, stepKeyFor(decision.nodeId, ordinal), `${decision.reason} approval`, buildGateSummary(decision, ctx.outputsByNode, ctx.lastVerdict));
454
586
  const verdict = gateVerdict(human, decision.outcomes);
455
587
  return { lastResult: verdict ? { verdict } : {}, ...(verdict ? { lastVerdict: verdict } : {}), stepDelta: 0 };
456
588
  }
457
589
  case 'fork': {
458
- // Fork/join is supported by the core; the MVP feature-development pipeline has none. Record a
459
- // deterministic barrier arrival per branch (verdict undefined) so an `all` join proceeds. A richer
460
- // concurrent-branch executor (DBOS child workflows) is a later slice (§14 Q1).
461
590
  await appendEvent({
462
591
  runId,
463
592
  taskId,
@@ -466,67 +595,225 @@ export function makeDataDrivenTask(runStepFn, deps) {
466
595
  type: 'pipeline_fork',
467
596
  payload: { nodeId: decision.nodeId, branches: decision.branches.map((b) => b.id), joinId: decision.joinId },
468
597
  });
469
- return {
470
- lastResult: { joinArrivals: decision.branches.map((b, idx) => ({ branchId: b.id, seq: idx + 1 })) },
471
- stepDelta: 0,
472
- };
598
+ return executeForkBranches(decision, ctx);
473
599
  }
474
600
  case 'startTimer':
475
- // `wait` nodes are rare (§1) and unused by the MVP templates; a durable timer executor is a later
476
- // slice. Treat as an immediate (recorded) resume so a template using it still advances.
477
601
  return { lastResult: {}, stepDelta: 0 };
478
602
  }
479
603
  }
480
- // ── Effect executors ─────────────────────────────────────────────────────────
481
- /** invokeRole → dispatch via the existing runStep DBOS step; validate result vs resultSchema. */
482
604
  async function invokeRole(runId, decision, node, ctx, inputs, stepKey) {
605
+ const binding = resolveRoleBinding(ctx, decision);
606
+ return invokeRoleAttempts({ runId, decision, node, ctx, inputs, stepKey, binding });
607
+ }
608
+ function resolveRoleBinding(ctx, decision) {
483
609
  const binding = ctx.bindingByRef.get(decision.roleRef);
484
- if (!binding) {
485
- // A VALID template's caps resolve at run start; an unresolved roleRef is a fatal config gap.
610
+ if (binding === undefined) {
486
611
  throw new Error(`CAPABILITY_UNRESOLVED: roleRef ${decision.roleRef} has no route binding`);
487
612
  }
488
- // stepKey is ordinal-aware (0016 §4.1): distinct per loop iteration, stable across replay. The
489
- // hydrated `inputs` (consumed upstream outputs) ride in stepInput → the runner renders them as a
490
- // `## Inputs (from previous steps)` prompt section (build-context).
491
- const stepInput = Object.keys(inputs).length > 0 ? { nodeId: decision.nodeId, inputs } : { nodeId: decision.nodeId };
492
- const result = await runStepFn(runId, binding.rowId, stepKey, stepInput, binding.resolvedRunnerId, ctx.executionProfile);
493
- // runStep converts a runner-process crash into a blocking attempt (needsHuman + verdict BLOCKER);
494
- // that is a domain failure of the node → route via §6 precedence as a result-invalid/failed effect.
613
+ return binding;
614
+ }
615
+ async function invokeRoleAttempts(input) {
616
+ const { runId, decision, node, ctx, inputs, stepKey, binding } = input;
617
+ const attemptIds = [];
618
+ for (let attemptNo = 1; attemptNo <= ctx.runnerRetryPolicy.maxAttempts; attemptNo++) {
619
+ const physicalAttempt = physicalAttemptFor(runId, stepKey, attemptNo);
620
+ attemptIds.push(physicalAttempt.attemptId);
621
+ const result = await runStepFn(runId, binding.rowId, stepKey, stepInputForAttempt(decision.nodeId, inputs, physicalAttempt), binding.resolvedRunnerId, ctx.executionProfile, physicalAttempt, ctx.template.verdicts.domain);
622
+ const needsHuman = await maybeHandleNeedsHumanRoleResult({
623
+ ...input,
624
+ attemptIds,
625
+ physicalAttempt,
626
+ result,
627
+ });
628
+ if (needsHuman === 'retry')
629
+ continue;
630
+ if (needsHuman)
631
+ return needsHuman;
632
+ const failed = roleValidationFailure(ctx.template, node, result, physicalAttempt);
633
+ if (failed)
634
+ return failed;
635
+ let output = result.output;
636
+ const hasProducedChange = producedChangeArtifact(output) !== undefined;
637
+ const shouldCaptureChange = nodeProducesChange(node) &&
638
+ !hasProducedChange &&
639
+ ctx.live &&
640
+ runnerProducesWorktreeChanges(binding.resolvedRunnerId);
641
+ if (shouldCaptureChange) {
642
+ const artifactRef = artifactRefFromResult(result);
643
+ const change = await captureChangeFn({
644
+ runId,
645
+ taskId: ctx.taskId,
646
+ title: ctx.title,
647
+ base: ctx.base,
648
+ nodeId: node.id,
649
+ attemptId: physicalAttempt.attemptId,
650
+ ...(ctx.issueRef ? { issueRef: ctx.issueRef } : {}),
651
+ ...(artifactRef ? { artifactRef } : {}),
652
+ });
653
+ output = attachProducedChange(output, change);
654
+ }
655
+ return {
656
+ failed: false,
657
+ verdict: domainVerdictOf(result),
658
+ output,
659
+ attemptId: physicalAttempt.attemptId,
660
+ attemptsMade: attemptNo,
661
+ };
662
+ }
663
+ throw new InterpretError(`runner retry loop for ${stepKey} exceeded maxAttempts`);
664
+ }
665
+ async function maybeHandleNeedsHumanRoleResult(input) {
666
+ const { result, node, physicalAttempt } = input;
495
667
  if (result.needsHuman) {
496
- return { failed: true, errorCode: REVO_RESULT_INVALID, reason: `${REVO_RESULT_INVALID}: node ${node.id} returned needsHuman instead of a valid result` };
668
+ const transient = transientRunnerFailure(result);
669
+ if (transient === undefined) {
670
+ const safeLesson = String(redactEventPayload(result.lesson ?? `agent ${node.id} reported needsHuman`));
671
+ return { blocked: true, reason: 'agent-needs-human', lesson: safeLesson, attemptsMade: physicalAttempt.attemptNo };
672
+ }
673
+ return handleTransientRoleResult({ ...input, transient });
497
674
  }
498
- if (!resultSatisfiesSchema(node, result)) {
499
- return { failed: true, errorCode: REVO_RESULT_INVALID, reason: `${REVO_RESULT_INVALID}: node ${node.id} result did not satisfy resultSchema ${String('resultSchema' in node ? node.resultSchema : '')}` };
675
+ return undefined;
676
+ }
677
+ async function handleTransientRoleResult(input) {
678
+ const { runId, node, ctx, stepKey, attemptIds, physicalAttempt, transient } = input;
679
+ const policy = ctx.runnerRetryPolicy;
680
+ if (shouldRetryTransient(transient, physicalAttempt, policy)) {
681
+ const nextAttempt = physicalAttemptFor(runId, stepKey, physicalAttempt.attemptNo + 1);
682
+ await appendRunnerRetryScheduled({
683
+ runId,
684
+ taskId: ctx.taskId,
685
+ stepKey,
686
+ nodeId: node.id,
687
+ failedAttempt: physicalAttempt,
688
+ nextAttempt,
689
+ policy,
690
+ transient,
691
+ });
692
+ if (policy.backoffMs > 0)
693
+ await deps.sleep(policy.backoffMs);
694
+ return 'retry';
500
695
  }
501
- const verdictProblem = resultVerdictProblem(ctx.template, node, result);
502
- if (verdictProblem) {
503
- return { failed: true, errorCode: REVO_RESULT_INVALID, reason: verdictProblem };
696
+ const retry = runnerRetryBlockPayload({
697
+ transient,
698
+ attemptIds,
699
+ lastAttempt: physicalAttempt,
700
+ policy,
701
+ attemptsExhausted: transient.retryable && physicalAttempt.attemptNo >= policy.maxAttempts,
702
+ });
703
+ if (retry.attemptsExhausted) {
704
+ await appendRunnerRetryExhausted({
705
+ runId,
706
+ taskId: ctx.taskId,
707
+ stepKey,
708
+ nodeId: node.id,
709
+ retry,
710
+ idempotencyKey: physicalAttempt.attemptId,
711
+ });
504
712
  }
505
- return { failed: false, verdict: domainVerdictOf(result), output: result.output };
713
+ return {
714
+ blocked: true,
715
+ reason: retry.reason,
716
+ lesson: retry.lesson,
717
+ retry,
718
+ attemptsMade: physicalAttempt.attemptNo,
719
+ };
720
+ }
721
+ function shouldRetryTransient(transient, attempt, policy) {
722
+ return transient.retryable && attempt.attemptNo < policy.maxAttempts;
723
+ }
724
+ function roleValidationFailure(template, node, result, physicalAttempt) {
725
+ if (resultSatisfiesSchema(node, result)) {
726
+ const verdictProblem = resultVerdictProblem(template, node, result);
727
+ if (verdictProblem)
728
+ return invalidRoleResult(verdictProblem, physicalAttempt);
729
+ return undefined;
730
+ }
731
+ return invalidRoleResult(`${REVO_RESULT_INVALID}: node ${node.id} result did not satisfy resultSchema ${String('resultSchema' in node ? node.resultSchema : '')}`, physicalAttempt);
732
+ }
733
+ function runnerRetryBlockPayload(input) {
734
+ const { transient, attemptIds, lastAttempt, policy, attemptsExhausted } = input;
735
+ const reason = runnerBlockReason(transient);
736
+ const lesson = runnerBlockLesson(transient);
737
+ return {
738
+ attemptsExhausted,
739
+ attemptsMade: lastAttempt.attemptNo,
740
+ maxAttempts: policy.maxAttempts,
741
+ attemptIds: [...attemptIds],
742
+ lastAttemptId: lastAttempt.attemptId,
743
+ reason,
744
+ lesson,
745
+ ...optionalFailureKind(transient.failureKind),
746
+ transientKind: transient.transientKind,
747
+ ...optionalTiming(transient.timing),
748
+ };
749
+ }
750
+ async function appendRunnerRetryScheduled(input) {
751
+ const { runId, taskId, stepKey, nodeId, failedAttempt, nextAttempt, policy, transient } = input;
752
+ await appendEvent({
753
+ runId,
754
+ taskId,
755
+ stepId: '',
756
+ stepKey,
757
+ type: 'runner_retry_scheduled',
758
+ idempotencyKey: failedAttempt.attemptId,
759
+ payload: {
760
+ nodeId,
761
+ failedAttemptNo: failedAttempt.attemptNo,
762
+ failedAttemptId: failedAttempt.attemptId,
763
+ nextAttemptNo: nextAttempt.attemptNo,
764
+ nextAttemptId: nextAttempt.attemptId,
765
+ maxAttempts: policy.maxAttempts,
766
+ backoffMs: policy.backoffMs,
767
+ reason: runnerBlockReason(transient),
768
+ lesson: runnerBlockLesson(transient),
769
+ ...optionalFailureKind(transient.failureKind),
770
+ transientKind: transient.transientKind,
771
+ ...optionalTiming(transient.timing),
772
+ },
773
+ });
774
+ }
775
+ async function appendRunnerRetryExhausted(input) {
776
+ const { runId, taskId, stepKey, nodeId, retry, idempotencyKey } = input;
777
+ await appendEvent({
778
+ runId,
779
+ taskId,
780
+ stepId: '',
781
+ stepKey,
782
+ type: 'runner_retry_exhausted',
783
+ idempotencyKey,
784
+ payload: {
785
+ nodeId,
786
+ ...retry,
787
+ },
788
+ });
506
789
  }
507
- /**
508
- * invokeScript → the built-in system SCRIPT library. The only built-in v1 script is the integrator
509
- * (`script:integrator`); it dispatches to the real integrator (live) or the stub (script) exactly as
510
- * the hardcoded path does — runner-wins via the resolved binding (D7).
511
- *
512
- * Two distinct outcomes the routing data discriminates (parity with the old engine):
513
- * - `blocked`: the integrator needs a human (nothing to integrate, ambiguous PRs, refused identity,
514
- * non-JSON pr view). We emit a `pipeline_blocked` event carrying the human-readable lesson (so the
515
- * human sees WHY — the persist boundary redacts any token), then the node's `catch[revo.ScriptBlocked]`
516
- * routes to a `blocked` terminal.
517
- * - `failed`: the integrator THREW (a gh/push error). `catch[revo.ScriptFailed]` routes to a `failed`
518
- * terminal; the run's top-level catch failRuns (the thrown reason is recorded, redacted).
519
- */
520
790
  async function invokeScript(runId, decision, ctx, bindingByRef, stepKey, inputs) {
791
+ if (decision.scriptRef === 'script:cleanupWorktree') {
792
+ try {
793
+ await releaseWorktreeFn(runId, ctx.taskId);
794
+ }
795
+ catch { /* best-effort */ }
796
+ await appendEvent({ runId, taskId: ctx.taskId, stepId: '', stepKey, type: 'worktree_released', payload: { nodeId: decision.nodeId } });
797
+ return { outcome: 'ok', pointer: { released: true } };
798
+ }
521
799
  const isConfirmMerge = decision.scriptRef === 'script:confirmMerge';
522
800
  const isPollPr = decision.scriptRef === 'script:pollPr';
523
801
  const isRespondThreads = decision.scriptRef === 'script:respondThreads';
524
802
  const binding = bindingByRef.get(decision.scriptRef) ?? bindingByRef.get('script:integrator');
525
- // respondThreads consumes `triage` (plan 0018) — ride the hydrated input on the integrator input so
526
- // the live script can reply/resolve the triaged threads without a live Revisium read.
527
- const integratorInput = { runId, taskId: ctx.taskId, title: ctx.title, base: ctx.base, ...(inputs.triage !== undefined ? { triage: inputs.triage } : {}) };
528
- // A script node whose resolved runner mechanically performs the merge uses the REAL script;
529
- // otherwise the pure stub (zero git/gh). Absent a binding (template-only script), default to stub.
803
+ const change = producedChangeFromInputs(inputs);
804
+ const mergeReadiness = mergeReadinessFromInputs(inputs);
805
+ const issueRef = ctx.issueRef;
806
+ const changeForIntegrator = change ? changeWithRunIssueRef(change, issueRef) : undefined;
807
+ const integratorInput = {
808
+ runId,
809
+ taskId: ctx.taskId,
810
+ title: ctx.title,
811
+ base: ctx.base,
812
+ ...(issueRef ? { issueRef } : {}),
813
+ ...(changeForIntegrator ? { change: changeForIntegrator } : {}),
814
+ ...(inputs.triage === undefined ? {} : { triage: inputs.triage }),
815
+ ...(mergeReadiness ? { mergeReadiness } : {}),
816
+ };
530
817
  const useReal = !!binding && runnerUsesRealIntegrator(binding.resolvedRunnerId);
531
818
  let result;
532
819
  try {
@@ -555,8 +842,6 @@ export function makeDataDrivenTask(runStepFn, deps) {
555
842
  return { outcome: 'failed' };
556
843
  }
557
844
  if ('needsHuman' in result) {
558
- // Surface the blocking reason as pipeline_blocked (the persist boundary redacts any token, D15) so
559
- // the human sees WHY the script could not proceed — exactly as the old engine's blockPipeline.
560
845
  const reason = isConfirmMerge ? 'confirm-merge' : isPollPr ? 'poll-pr' : isRespondThreads ? 'respond-threads' : 'integrate';
561
846
  await appendEvent({
562
847
  runId,
@@ -576,13 +861,23 @@ export function makeDataDrivenTask(runStepFn, deps) {
576
861
  stepId: '',
577
862
  stepKey,
578
863
  type: 'merge_confirmed',
579
- payload: { prNumber: merged.prNumber, prUrl: merged.prUrl },
864
+ payload: {
865
+ prNumber: merged.prNumber,
866
+ prUrl: merged.prUrl,
867
+ ...(merged.issueRef ? { issueRef: merged.issueRef } : {}),
868
+ },
580
869
  });
581
- return { outcome: 'ok', pointer: { merged: true, prNumber: merged.prNumber, prUrl: merged.prUrl } };
870
+ return {
871
+ outcome: 'ok',
872
+ pointer: {
873
+ merged: true,
874
+ prNumber: merged.prNumber,
875
+ prUrl: merged.prUrl,
876
+ ...(merged.issueRef ? { issueRef: merged.issueRef } : {}),
877
+ },
878
+ };
582
879
  }
583
880
  if (isPollPr) {
584
- // pollPr CLASSIFIES the feedback: its verdict (review_changes/ci_changes/clean) routes the prRouter
585
- // choice (§8 — a script may emit a domain verdict the routing data acts on, same as an agent).
586
881
  const feedback = result;
587
882
  await appendEvent({
588
883
  runId,
@@ -590,7 +885,15 @@ export function makeDataDrivenTask(runStepFn, deps) {
590
885
  stepId: '',
591
886
  stepKey,
592
887
  type: 'pr_polled',
593
- payload: { prNumber: feedback.prNumber, verdict: feedback.verdict, ciFailures: feedback.ciFailures.length, reviewThreads: feedback.reviewThreads.length },
888
+ payload: {
889
+ prNumber: feedback.prNumber,
890
+ headSha: feedback.headSha,
891
+ verdict: feedback.verdict,
892
+ evidence: feedback.evidence,
893
+ ciFailures: feedback.ciFailures.length,
894
+ reviewThreads: feedback.reviewThreads.length,
895
+ ...(feedback.issueRef ? { issueRef: feedback.issueRef } : {}),
896
+ },
594
897
  });
595
898
  return { outcome: 'ok', pointer: feedback, verdict: feedback.verdict };
596
899
  }
@@ -613,11 +916,27 @@ export function makeDataDrivenTask(runStepFn, deps) {
613
916
  stepId: '',
614
917
  stepKey,
615
918
  type: 'integrate_succeeded',
616
- payload: { prUrl: integrated.prUrl, branch: integrated.branch, prNumber: integrated.prNumber },
919
+ payload: {
920
+ prUrl: integrated.prUrl,
921
+ branch: integrated.branch,
922
+ prNumber: integrated.prNumber,
923
+ headSha: integrated.headSha,
924
+ status: integrated.status,
925
+ ...(integrated.issueRef ? { issueRef: integrated.issueRef } : {}),
926
+ },
617
927
  });
618
- return { outcome: 'ok', pointer: { prUrl: integrated.prUrl, branch: integrated.branch, prNumber: integrated.prNumber } };
928
+ return {
929
+ outcome: 'ok',
930
+ pointer: {
931
+ prUrl: integrated.prUrl,
932
+ branch: integrated.branch,
933
+ prNumber: integrated.prNumber,
934
+ headSha: integrated.headSha,
935
+ status: integrated.status,
936
+ ...(integrated.issueRef ? { issueRef: integrated.issueRef } : {}),
937
+ },
938
+ };
619
939
  }
620
- /** Terminal: finish the run in Revisium per the core's terminal status. */
621
940
  async function finish(runId, status, verdict, steps, failureReason = '') {
622
941
  if (status === 'succeeded') {
623
942
  await completeRun(runId, { actor: 'pipeline', source: 'data-driven-complete', verdict, iterations: steps });
@@ -638,26 +957,19 @@ export function makeDataDrivenTask(runStepFn, deps) {
638
957
  }
639
958
  return { runId, status, verdict, steps };
640
959
  }
641
- /**
642
- * Block the run early with a human-readable lesson (the engine-level preflight guard — there is no
643
- * graph node for it). Emits a lesson-bearing `pipeline_blocked` (token-redacted at the persist
644
- * boundary) + marks the Revisium run-row blocked, then returns a `blocked` result WITHOUT entering the
645
- * graph. Mirrors the old engine's `blockPipeline({ reason })`.
646
- */
647
- async function blockWithLesson(runId, taskId, reason, lesson, steps) {
960
+ async function blockWithLesson(runId, taskId, reason, lesson, steps, retry) {
648
961
  await appendEvent({
649
962
  runId,
650
963
  taskId,
651
964
  stepId: '',
652
965
  stepKey: 'pipeline',
653
966
  type: 'pipeline_blocked',
654
- payload: { reason, lesson },
967
+ payload: pipelineBlockedPayload(reason, lesson, retry),
655
968
  });
656
969
  await blockRun(runId, { actor: 'pipeline', source: `data-driven-${reason}`, reason });
657
970
  return { runId, status: 'blocked', verdict: 'blocked', steps };
658
971
  }
659
972
  }
660
- /** Resolve a node id against the template (a VALID template never dangles — guarded defensively). */
661
973
  function resolveNode(template, nodeId) {
662
974
  const node = template.nodes[nodeId];
663
975
  if (!node)