@revisium/orchestrator 0.1.0-alpha.8 → 0.1.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 (127) hide show
  1. package/README.md +150 -130
  2. package/control-plane/default-playbook/catalog/pipelines.json +280 -11
  3. package/control-plane/default-playbook/package.json +1 -1
  4. package/control-plane/default-playbook/prompts/developer.md +1 -0
  5. package/control-plane/default-playbook/prompts/triager.md +3 -0
  6. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
  7. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
  8. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
  9. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
  10. package/dist/api/graphql-api/pr/model/pr-readiness.model.js +30 -0
  11. package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
  12. package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
  13. package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
  14. package/dist/api/graphql-api/runs/model/run.model.js +6 -0
  15. package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
  16. package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
  17. package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
  18. package/dist/cli/commands/lifecycle.js +113 -8
  19. package/dist/cli/commands/lifecycle.js.map +1 -1
  20. package/dist/cli/commands/mcp.js +8 -1
  21. package/dist/cli/commands/mcp.js.map +1 -1
  22. package/dist/cli/commands/process-tree.js +42 -0
  23. package/dist/cli/commands/process-tree.js.map +1 -1
  24. package/dist/cli/commands/rogue-reaper.js +63 -0
  25. package/dist/cli/commands/rogue-reaper.js.map +1 -0
  26. package/dist/config.js +9 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/control-plane/bootstrap.js +14 -1
  29. package/dist/control-plane/bootstrap.js.map +1 -1
  30. package/dist/control-plane/client-transport.js +1 -1
  31. package/dist/control-plane/client-transport.js.map +1 -1
  32. package/dist/control-plane/default-playbook-policy.js +593 -0
  33. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  34. package/dist/control-plane/seed-default-playbook.js +77 -8
  35. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  36. package/dist/e2e/kit/agents.js +15 -21
  37. package/dist/e2e/kit/agents.js.map +1 -1
  38. package/dist/e2e/kit/assertions.js +18 -13
  39. package/dist/e2e/kit/assertions.js.map +1 -1
  40. package/dist/e2e/kit/fake-integrator.js +11 -2
  41. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  42. package/dist/e2e/kit/gh-emulator.js +2 -1
  43. package/dist/e2e/kit/gh-emulator.js.map +1 -1
  44. package/dist/e2e/kit/git-target-repo.js +1 -1
  45. package/dist/engine/dbos.service.js +3 -0
  46. package/dist/engine/dbos.service.js.map +1 -1
  47. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  48. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  49. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  50. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  51. package/dist/host/daemon.js +14 -1
  52. package/dist/host/daemon.js.map +1 -1
  53. package/dist/host/host-runtime.js +20 -2
  54. package/dist/host/host-runtime.js.map +1 -1
  55. package/dist/mcp/mcp-capabilities.js +16 -1
  56. package/dist/mcp/mcp-capabilities.js.map +1 -1
  57. package/dist/mcp/mcp-facade.service.js +325 -12
  58. package/dist/mcp/mcp-facade.service.js.map +1 -1
  59. package/dist/mcp/mcp-tools.js +58 -12
  60. package/dist/mcp/mcp-tools.js.map +1 -1
  61. package/dist/observability/activity-signal.js +107 -0
  62. package/dist/observability/activity-signal.js.map +1 -0
  63. package/dist/observability/index.js +1 -0
  64. package/dist/observability/index.js.map +1 -1
  65. package/dist/pipeline/data-driven-task.workflow.js +549 -44
  66. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  67. package/dist/pipeline/pipeline.service.js +195 -101
  68. package/dist/pipeline/pipeline.service.js.map +1 -1
  69. package/dist/pipeline/route-contract.js +4 -3
  70. package/dist/pipeline/route-contract.js.map +1 -1
  71. package/dist/pipeline-core/interpret.js +2 -0
  72. package/dist/pipeline-core/interpret.js.map +1 -1
  73. package/dist/pipeline-core/kit/builders.js +2 -0
  74. package/dist/pipeline-core/kit/builders.js.map +1 -1
  75. package/dist/pipeline-core/kit/fixtures.js +72 -21
  76. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  77. package/dist/pipeline-core/types.js +2 -0
  78. package/dist/pipeline-core/types.js.map +1 -1
  79. package/dist/pipeline-core/validate-dataflow.js +32 -0
  80. package/dist/pipeline-core/validate-dataflow.js.map +1 -1
  81. package/dist/playbook/import-mapper.js +8 -1
  82. package/dist/playbook/import-mapper.js.map +1 -1
  83. package/dist/poller/pr-readiness-core.js +299 -28
  84. package/dist/poller/pr-readiness-core.js.map +1 -1
  85. package/dist/revisium/playbooks.service.js +17 -2
  86. package/dist/revisium/playbooks.service.js.map +1 -1
  87. package/dist/revisium/run.service.js +1 -0
  88. package/dist/revisium/run.service.js.map +1 -1
  89. package/dist/run/append-event.js +8 -5
  90. package/dist/run/append-event.js.map +1 -1
  91. package/dist/run/create-run.js +5 -1
  92. package/dist/run/create-run.js.map +1 -1
  93. package/dist/run/inspect-run.js +30 -4
  94. package/dist/run/inspect-run.js.map +1 -1
  95. package/dist/run/issue-ref.js +88 -0
  96. package/dist/run/issue-ref.js.map +1 -0
  97. package/dist/runners/claude-code.service.js +2 -2
  98. package/dist/runners/claude-code.service.js.map +1 -1
  99. package/dist/runners/codex.service.js +2 -2
  100. package/dist/runners/codex.service.js.map +1 -1
  101. package/dist/runners/integrator-branch-naming.js +33 -13
  102. package/dist/runners/integrator-branch-naming.js.map +1 -1
  103. package/dist/runners/integrator.js +285 -72
  104. package/dist/runners/integrator.js.map +1 -1
  105. package/dist/runners/worktree.service.js +2 -2
  106. package/dist/runners/worktree.service.js.map +1 -1
  107. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  108. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  109. package/dist/task-control-plane/run-watch.service.js +512 -0
  110. package/dist/task-control-plane/run-watch.service.js.map +1 -0
  111. package/dist/task-control-plane/task-control-plane-api.service.js +277 -25
  112. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  113. package/dist/worker/artifact-store.js +21 -3
  114. package/dist/worker/artifact-store.js.map +1 -1
  115. package/dist/worker/build-context.js +19 -3
  116. package/dist/worker/build-context.js.map +1 -1
  117. package/dist/worker/claude-code-runner.js +76 -15
  118. package/dist/worker/claude-code-runner.js.map +1 -1
  119. package/dist/worker/codex-runner.js +40 -13
  120. package/dist/worker/codex-runner.js.map +1 -1
  121. package/dist/worker/process-executor.js +149 -20
  122. package/dist/worker/process-executor.js.map +1 -1
  123. package/dist/worker/runner-common.js +29 -0
  124. package/dist/worker/runner-common.js.map +1 -1
  125. package/dist/worker/runner.js +16 -1
  126. package/dist/worker/runner.js.map +1 -1
  127. package/package.json +1 -1
@@ -29,8 +29,63 @@
29
29
  */
30
30
  import { step as coreStep, initialState, validateTemplate, InterpretError, } from '../pipeline-core/index.js';
31
31
  import { runnerNeedsLivePreflight, runnerUsesRealIntegrator } from './route-contract.js';
32
+ import { redactEventPayload } from '../run/append-event.js';
33
+ import { redactSecrets } from '../control-plane/inbox.js';
34
+ import { fnv1a64Hex } from '../control-plane/steps.js';
35
+ import { normalizeIssueRef } from '../run/issue-ref.js';
36
+ import { RUNNER_IDLE_TIMEOUT_KIND, RUNNER_WALL_CLOCK_LIMIT_KIND, } from '../worker/process-executor.js';
32
37
  export const RUN_PROGRESS_EVENT_KEY = 'run-progress';
33
38
  const MAX_STEPS = 1_000; // a VALID template terminates; guards a data/loop authoring mistake at runtime.
39
+ const DEFAULT_RUNNER_TRANSIENT_MAX_ATTEMPTS = 2;
40
+ const DEFAULT_RUNNER_TRANSIENT_RETRY_BACKOFF_MS = 2_000;
41
+ function readPositiveIntegerEnv(env, key, fallback) {
42
+ const raw = env[key];
43
+ if (raw === undefined)
44
+ return fallback;
45
+ if (!/^\d+$/.test(raw))
46
+ throw new Error(`${key} must be a positive integer`);
47
+ const parsed = Number(raw);
48
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
49
+ throw new Error(`${key} must be a positive integer`);
50
+ return parsed;
51
+ }
52
+ function readNonNegativeIntegerEnv(env, key, fallback) {
53
+ const raw = env[key];
54
+ if (raw === undefined)
55
+ return fallback;
56
+ if (!/^\d+$/.test(raw))
57
+ throw new Error(`${key} must be a non-negative integer`);
58
+ const parsed = Number(raw);
59
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
60
+ throw new Error(`${key} must be a non-negative integer`);
61
+ return parsed;
62
+ }
63
+ export function resolveRunnerTransientRetryPolicy(env = process.env) {
64
+ return {
65
+ maxAttempts: readPositiveIntegerEnv(env, 'REVO_RUNNER_TRANSIENT_MAX_ATTEMPTS', DEFAULT_RUNNER_TRANSIENT_MAX_ATTEMPTS),
66
+ backoffMs: readNonNegativeIntegerEnv(env, 'REVO_RUNNER_TRANSIENT_RETRY_BACKOFF_MS', DEFAULT_RUNNER_TRANSIENT_RETRY_BACKOFF_MS),
67
+ };
68
+ }
69
+ function physicalAttemptFor(runId, stepKey, attemptNo) {
70
+ const attemptKey = `${runId}|${stepKey}|${attemptNo}`;
71
+ return {
72
+ attemptNo,
73
+ attemptId: `attempt_${fnv1a64Hex(attemptKey)}`,
74
+ };
75
+ }
76
+ function stepInputForAttempt(nodeId, inputs, attempt) {
77
+ return {
78
+ nodeId,
79
+ attempt: { attemptNo: attempt.attemptNo, attemptId: attempt.attemptId },
80
+ ...(Object.keys(inputs).length > 0 ? { inputs } : {}),
81
+ };
82
+ }
83
+ function optionalTiming(timing) {
84
+ return timing === undefined ? {} : { timing };
85
+ }
86
+ function optionalFailureKind(failureKind) {
87
+ return failureKind === undefined ? {} : { failureKind };
88
+ }
34
89
  /**
35
90
  * Reserved engine error codes (matched only by a node's `catch`, §3/§6).
36
91
  *
@@ -46,6 +101,31 @@ const REVO_SCRIPT_FAILED = 'revo.ScriptFailed';
46
101
  const REVO_SCRIPT_BLOCKED = 'revo.ScriptBlocked';
47
102
  const REVO_RESULT_INVALID = 'revo.ResultInvalid';
48
103
  const REVO_INPUT_MISSING = 'revo.InputMissing';
104
+ function invalidRoleResult(reason, physicalAttempt) {
105
+ return {
106
+ failed: true,
107
+ errorCode: REVO_RESULT_INVALID,
108
+ reason,
109
+ attemptId: physicalAttempt.attemptId,
110
+ attemptsMade: physicalAttempt.attemptNo,
111
+ };
112
+ }
113
+ function runnerBlockReason(transient) {
114
+ if (transient.failureKind)
115
+ return transient.failureKind;
116
+ return `runner-transient-failure:${transient.transientKind}`;
117
+ }
118
+ function runnerBlockLesson(transient) {
119
+ const safe = String(redactEventPayload(transient.reason));
120
+ if (transient.failureKind)
121
+ return `${transient.failureKind}: ${safe || 'runner failed'}`;
122
+ return `runner-transient-failure (${transient.transientKind}): ${safe || 'runner failed'}`;
123
+ }
124
+ function pipelineBlockedPayload(reason, lesson, retry) {
125
+ if (retry === undefined)
126
+ return { reason, lesson };
127
+ return { ...retry };
128
+ }
49
129
  /** Per-node execution stepKey: the bare nodeId on the first entry (stable ids for existing tests), an
50
130
  * ordinal-suffixed key on loop re-entries so attempts/events/outputs are distinct per iteration (0016
51
131
  * §4.1 — fixes the latent 0015 stepKey-reuse collision). */
@@ -62,6 +142,125 @@ function nextOrdinal(byNode, nodeId) {
62
142
  function isRecord(value) {
63
143
  return value !== null && typeof value === 'object' && !Array.isArray(value);
64
144
  }
145
+ function producedChangeArtifact(value) {
146
+ if (!isRecord(value))
147
+ return undefined;
148
+ const candidate = isRecord(value.change) ? value.change : value;
149
+ const branch = candidate.branch;
150
+ const headSha = candidate.headSha;
151
+ if (typeof branch !== 'string' || branch.trim().length === 0)
152
+ return undefined;
153
+ if (typeof headSha !== 'string' || headSha.trim().length === 0)
154
+ return undefined;
155
+ const issueRef = normalizeArtifactIssueRef(candidate.issueRef);
156
+ return {
157
+ branch,
158
+ headSha,
159
+ ...(issueRef ? { issueRef } : {}),
160
+ ...(typeof candidate.worktreePath === 'string' && candidate.worktreePath.trim() ? { worktreePath: candidate.worktreePath } : {}),
161
+ ...(typeof candidate.artifactRef === 'string' && candidate.artifactRef.trim() ? { artifactRef: candidate.artifactRef } : {}),
162
+ ...(typeof candidate.prNumber === 'number' && Number.isSafeInteger(candidate.prNumber) ? { prNumber: candidate.prNumber } : {}),
163
+ };
164
+ }
165
+ function normalizeArtifactIssueRef(value) {
166
+ try {
167
+ return normalizeIssueRef(value, 'change.issueRef');
168
+ }
169
+ catch {
170
+ return undefined;
171
+ }
172
+ }
173
+ function changeWithRunIssueRef(change, issueRef) {
174
+ const normalized = { ...change };
175
+ if (issueRef) {
176
+ normalized.issueRef = issueRef;
177
+ }
178
+ else {
179
+ delete normalized.issueRef;
180
+ }
181
+ return normalized;
182
+ }
183
+ function producedChangeFromInputs(inputs) {
184
+ for (const key of ['reviewChange', 'ciChange', 'reworkChange', 'developerChange', 'change']) {
185
+ const artifact = producedChangeArtifact(inputs[key]);
186
+ if (artifact)
187
+ return artifact;
188
+ }
189
+ return undefined;
190
+ }
191
+ function mergeReadinessFromInputs(inputs) {
192
+ const value = inputs.mergeReadiness;
193
+ if (!isRecord(value))
194
+ return undefined;
195
+ const headSha = value.headSha;
196
+ if (typeof headSha !== 'string' || headSha.trim().length === 0)
197
+ return undefined;
198
+ return { headSha };
199
+ }
200
+ function attachProducedChange(output, change) {
201
+ if (isRecord(output))
202
+ return { ...output, change };
203
+ return { summary: output, change };
204
+ }
205
+ function nodeProducesChange(node) {
206
+ return (node.kind === 'agent' || node.kind === 'script') &&
207
+ node.produces?.name === 'change' &&
208
+ node.resultSchema === 'schema:change';
209
+ }
210
+ function runnerProducesWorktreeChanges(runnerId) {
211
+ return runnerId === 'claude-code' || runnerId === 'codex';
212
+ }
213
+ function artifactRefFromResult(result) {
214
+ const artifacts = result.artifacts;
215
+ const processArtifact = isRecord(artifacts) && isRecord(artifacts.process) ? artifacts.process : artifacts;
216
+ if (!isRecord(processArtifact))
217
+ return undefined;
218
+ return typeof processArtifact.ref === 'string' ? processArtifact.ref : undefined;
219
+ }
220
+ function publicTimeoutFailureKind(value) {
221
+ return value === RUNNER_IDLE_TIMEOUT_KIND || value === RUNNER_WALL_CLOCK_LIMIT_KIND
222
+ ? value
223
+ : undefined;
224
+ }
225
+ function transientRunnerFailure(result) {
226
+ const output = result.output;
227
+ if (!isRecord(output) || output.error !== 'runner_failed')
228
+ return undefined;
229
+ const reason = typeof output.reason === 'string' ? output.reason : '';
230
+ const failureKind = publicTimeoutFailureKind(output.failureKind);
231
+ const legacyKind = transientKind(reason);
232
+ const retryableCandidate = output.retryableCandidate !== false;
233
+ return {
234
+ reason,
235
+ transientKind: failureKind ? 'timeout' : legacyKind,
236
+ retryableCandidate,
237
+ retryable: retryableCandidate && (failureKind !== undefined || legacyKind !== 'unknown'),
238
+ ...optionalFailureKind(failureKind),
239
+ ...optionalTiming(output.timing),
240
+ };
241
+ }
242
+ /**
243
+ * Classify a transient runner failure into a SHORT, machine-readable kind so a consumer can tell
244
+ * timeout / rate-limit / crash apart from `pipeline_blocked.payload.reason` alone (the full detail
245
+ * stays in the lesson). Checked timeout-first so a "exited 1 (timeout)" message reads as a timeout.
246
+ */
247
+ function transientKind(reason) {
248
+ if (/exceeded\s*\d+\s*ms|timed?\s*out|\btimeout\b/i.test(reason))
249
+ return 'timeout';
250
+ if (/\b429\b|rate.?limit|session limit/i.test(reason))
251
+ return 'rate_limit';
252
+ if (isLegacyRetryableCrashReason(reason))
253
+ return 'crash';
254
+ return 'unknown';
255
+ }
256
+ function isLegacyRetryableCrashReason(reason) {
257
+ 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
258
+ .test(reason)) {
259
+ return false;
260
+ }
261
+ return /\b(signal|crash(?:ed)?|killed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|ENOMEM|EAGAIN)\b/i
262
+ .test(reason);
263
+ }
65
264
  /** Extract the DOMAIN verdict from the only supported source: top-level AttemptResult.verdict. */
66
265
  function domainVerdictOf(result) {
67
266
  if (typeof result.verdict === 'string' && result.verdict.trim().length > 0) {
@@ -136,6 +335,66 @@ function gateTopicFor(reason) {
136
335
  return 'question';
137
336
  return 'plan';
138
337
  }
338
+ // D3 — enrich the gate inbox row with the artifact under review + the reviewer verdict, inline, so an
339
+ // approver decides without digging the agent log. `pushInbox` has no size cap (unlike run-outputs'
340
+ // PAYLOAD_MAX), so the artifact is budgeted HERE: over-budget → a head preview + a payload_ref locator,
341
+ // never the full payload. The inbox id is keyed by runId|gateKey only, so a larger context does not
342
+ // change it → pushInbox stays idempotent on replay. The payload is also secret/token redacted at this
343
+ // build site (pushInbox only masks secret-NAMED keys, not token SHAPES in free-text) — see gateArtifactView.
344
+ export const GATE_ARTIFACT_MAX = 16_000;
345
+ export const GATE_PREVIEW_CHARS = 4_000;
346
+ /** Latest (or pinned-ordinal) output row for a gate ref, or undefined if the producer has not run. */
347
+ function resolveGateRow(ref, outputsByNode) {
348
+ if (!ref)
349
+ return undefined;
350
+ const produced = outputsByNode.get(ref.node) ?? [];
351
+ if (produced.length === 0)
352
+ return undefined;
353
+ if (typeof ref.iteration === 'number')
354
+ return produced.find((o) => o.ordinal === ref.iteration);
355
+ return produced[produced.length - 1]; // 'latest' (and 'all' → the most recent for an inline view)
356
+ }
357
+ /**
358
+ * Inline artifact view with the 16KB budget. Secrets + token shapes are scrubbed BEFORE inlining or
359
+ * previewing — mirroring run-outputs.ts (the sibling persist boundary): pushInbox only masks
360
+ * secret-NAMED keys, so a token shape (`ghp_…`) in a free-text field (e.g. pollPr's prFeedback) would
361
+ * otherwise persist verbatim. Over-budget → a head preview (of the redacted serialization) + an
362
+ * `attempt:` locator (the full artifact is recoverable from that attempt's agent log).
363
+ */
364
+ function gateArtifactView(row, as) {
365
+ const base = { nodeId: row.nodeId, name: as ?? row.name, schemaRef: row.schemaRef };
366
+ const safe = redactEventPayload(redactSecrets(row.payload) ?? null);
367
+ const serialized = JSON.stringify(safe ?? null);
368
+ // Budget on BYTES (not UTF-16 length) so a multi-byte payload can't slip past the cap.
369
+ if (Buffer.byteLength(serialized, 'utf8') <= GATE_ARTIFACT_MAX)
370
+ return { ...base, payload: safe };
371
+ return {
372
+ ...base,
373
+ truncated: true,
374
+ preview: serialized.slice(0, GATE_PREVIEW_CHARS),
375
+ payloadRef: `attempt:${row.attemptId ?? ''}`,
376
+ };
377
+ }
378
+ /**
379
+ * Build the enriched gate inbox summary from the workflow-local outputs (replay-safe — rebuilt
380
+ * identically, 0016 §6). `verdictFrom` resolves a node's verdict output; when it is NOT specified, the
381
+ * verdict defaults to the routing verdict that opened the gate (`lastVerdict`). Routing is unaffected —
382
+ * purely informational.
383
+ */
384
+ export function buildGateSummary(decision, outputsByNode, lastVerdict) {
385
+ const summary = { nodeId: decision.nodeId, outcomes: decision.outcomes };
386
+ const artRow = resolveGateRow(decision.gatedArtifact, outputsByNode);
387
+ if (artRow)
388
+ summary.gatedArtifact = gateArtifactView(artRow, decision.gatedArtifact?.as);
389
+ const verdictRow = resolveGateRow(decision.verdictFrom, outputsByNode);
390
+ if (verdictRow)
391
+ summary.reviewerVerdict = gateArtifactView(verdictRow);
392
+ // Only fall back to the routing verdict when no verdictFrom was REQUESTED — a specified-but-unresolved
393
+ // verdictFrom must not silently present the routing verdict as if it came from that source.
394
+ else if (!decision.verdictFrom && lastVerdict)
395
+ summary.reviewerVerdict = { verdict: lastVerdict };
396
+ return summary;
397
+ }
139
398
  /**
140
399
  * makeDataDrivenTask — DBOS-free factory for the dataDrivenTask async function (C1).
141
400
  *
@@ -143,7 +402,7 @@ function gateTopicFor(reason) {
143
402
  * production and tests. Returns a plain async function with the workflow signature.
144
403
  */
145
404
  export function makeDataDrivenTask(runStepFn, deps) {
146
- const { appendEvent, appendRunOutput, awaitHuman, completeRun, failRun, blockRun, loadRunTaskContext, integrateFn, runStub, confirmMergeFn, runConfirmStub, pollPrFn, runPollStub, respondThreadsFn, runRespondStub, preflightFn, createWorktreeFn, releaseWorktreeFn } = deps;
405
+ const { appendEvent, appendRunOutput, awaitHuman, completeRun, failRun, blockRun, loadRunTaskContext, integrateFn, runStub, confirmMergeFn, runConfirmStub, pollPrFn, runPollStub, respondThreadsFn, runRespondStub, captureChangeFn, preflightFn, createWorktreeFn, releaseWorktreeFn } = deps;
147
406
  /** Resolve a node's `consumes` from the workflow-local output accumulator — NOT live Revisium reads
148
407
  * (0016 §6 / consensus M4: a live read on replay can see rows written past the replay point). */
149
408
  function resolveConsumes(node, outputsByNode) {
@@ -177,7 +436,8 @@ export function makeDataDrivenTask(runStepFn, deps) {
177
436
  return { inputs };
178
437
  }
179
438
  /** Record a node's produced output to the workflow-local accumulator + Revisium (when `produces`). */
180
- async function recordOutput(runId, node, ordinal, output, outputsByNode) {
439
+ // (gate-summary helpers live at module scope below — they are pure over outputsByNode.)
440
+ async function recordOutput(runId, node, ordinal, attemptId, output, outputsByNode) {
181
441
  if (!('produces' in node) || !node.produces)
182
442
  return;
183
443
  const row = {
@@ -187,7 +447,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
187
447
  name: node.produces.name,
188
448
  schemaRef: ('resultSchema' in node && node.resultSchema) || '',
189
449
  payload: output,
190
- attemptId: stepKeyFor(node.id, ordinal),
450
+ attemptId,
191
451
  };
192
452
  const list = outputsByNode.get(node.id) ?? [];
193
453
  list.push(row);
@@ -220,7 +480,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
220
480
  if (diagnostics.length > 0) {
221
481
  throw new Error(`PINNED_TEMPLATE_INVALID: ${template.pipelineId} — ${diagnostics.map((d) => d.code).join(', ')}`);
222
482
  }
223
- const { taskId, title, base } = await loadRunTaskContext(runId);
483
+ const { taskId, title, base, issueRef } = await loadRunTaskContext(runId);
224
484
  // B5/B7 — live preflight: one memoized DBOS step, evaluated exactly once BEFORE the graph runs.
225
485
  // Skipped entirely when every selected binding resolves to a stub/script runner (mirrors the old
226
486
  // engine's gate). A `needsHuman` preflight blocks the run (clean/base invariant unmet) and surfaces
@@ -229,7 +489,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
229
489
  const live = route.roleBindings.some((b) => runnerNeedsLivePreflight(b.resolvedRunnerId));
230
490
  if (live) {
231
491
  // Preflight runs against the BASE checkout (resolveTaskCwd), BEFORE the worktree exists — its
232
- // fetch + clean/freshness checks protect the user's base repo. Ordering is load-bearing.
492
+ // fetch + clean/base-availability checks protect the user's base repo. Ordering is load-bearing.
233
493
  const pf = await preflightFn(taskId, base);
234
494
  if ('needsHuman' in pf) {
235
495
  return await blockWithLesson(runId, taskId, 'preflight', pf.lesson, 0);
@@ -247,9 +507,9 @@ export function makeDataDrivenTask(runStepFn, deps) {
247
507
  let result;
248
508
  try {
249
509
  if (live) {
250
- await createWorktreeFn(runId, taskId, title, base);
510
+ await createWorktreeFn(runId, taskId, title, base, issueRef);
251
511
  }
252
- result = await runGraph(runId, opts, taskId, title, base);
512
+ result = await runGraph(runId, opts, taskId, title, base, issueRef, live);
253
513
  return result;
254
514
  }
255
515
  finally {
@@ -264,8 +524,8 @@ export function makeDataDrivenTask(runStepFn, deps) {
264
524
  }
265
525
  }
266
526
  /** The pipeline-core interpretation loop, extracted so the worktree `finally` in runBody wraps it. */
267
- async function runGraph(runId, opts, taskId, title, base) {
268
- const { route, template } = opts;
527
+ async function runGraph(runId, opts, taskId, title, base, issueRef, live) {
528
+ const { route, template, runnerRetryPolicy } = opts;
269
529
  // Capability resolution map (GENERIC — no role-ids in the engine): roleRef/scriptRef → route binding.
270
530
  // The route's bindings are authoritative for runner dispatch (and durable on recovery via the DBOS
271
531
  // workflow input). A `role:<id>` handle resolves to the binding whose roleId matches `<id>`. A
@@ -300,14 +560,25 @@ export function makeDataDrivenTask(runStepFn, deps) {
300
560
  return await finish(runId, decision.status, lastVerdict, stepCount, lastFailureReason);
301
561
  }
302
562
  const eff = await applyDecision(decision, {
303
- runId, template, bindingByRef, executionProfile, taskId, title, base,
304
- effectOrdinalByNode, outputsByNode,
563
+ runId, template, bindingByRef, executionProfile, taskId, title, base, issueRef,
564
+ effectOrdinalByNode, outputsByNode, runnerRetryPolicy,
565
+ live,
566
+ // The verdict from the PRIOR effect (the reviewer/poller that routed into this node). At an
567
+ // awaitGate this is the routing verdict that opened the gate → D3's default reviewerVerdict.
568
+ lastVerdict,
305
569
  });
570
+ stepCount += eff.stepDelta;
571
+ // Engine-level short-circuit (agent needsHuman, classified by invokeRole): block the run at a
572
+ // visible, lesson-bearing terminal WITHOUT routing through the core's onFailure:'abort' machinery
573
+ // (which would fail the run). blockWithLesson emits the single `pipeline_blocked` (token-redacted
574
+ // at the persist boundary) + marks the run-row blocked — the same guard the preflight block uses.
575
+ if (eff.terminal) {
576
+ return await blockWithLesson(runId, taskId, eff.terminal.reason, eff.terminal.lesson, stepCount, eff.terminal.retry);
577
+ }
306
578
  lastResult = eff.lastResult;
307
579
  if (eff.lastVerdict !== undefined)
308
580
  lastVerdict = eff.lastVerdict;
309
581
  lastFailureReason = eff.failureReason ?? '';
310
- stepCount += eff.stepDelta;
311
582
  }
312
583
  throw new InterpretError(`data-driven ${template.pipelineId} did not terminate within ${MAX_STEPS} steps (template loop bug)`);
313
584
  }
@@ -335,19 +606,35 @@ export function makeDataDrivenTask(runStepFn, deps) {
335
606
  return { lastResult: { outcome: 'failed', errorCode: REVO_INPUT_MISSING }, lastVerdict: 'failed', stepDelta: 1 };
336
607
  }
337
608
  const result = await invokeRole(runId, decision, node, ctx, resolved.inputs, stepKey);
609
+ if ('blocked' in result) {
610
+ // needsHuman (deliberate agent block OR transient runner failure): short-circuit to a visible
611
+ // `blocked` terminal carrying the per-kind lesson (runGraph emits the pipeline_blocked + blocks
612
+ // the run-row via blockWithLesson). NOT a failed/aborted run — the block is recoverable.
613
+ return {
614
+ lastResult: undefined,
615
+ terminal: {
616
+ status: 'blocked',
617
+ reason: result.reason,
618
+ lesson: result.lesson,
619
+ ...(result.retry ? { retry: result.retry } : {}),
620
+ },
621
+ stepDelta: result.attemptsMade,
622
+ };
623
+ }
338
624
  if (result.failed) {
339
625
  await appendEvent({
340
626
  runId, taskId, stepId: '', stepKey, type: 'step_failed',
627
+ idempotencyKey: result.attemptId,
341
628
  payload: { nodeId: node.id, error: result.errorCode },
342
629
  });
343
- return { lastResult: { outcome: 'failed', errorCode: result.errorCode }, lastVerdict: 'failed', failureReason: result.reason, stepDelta: 1 };
630
+ return { lastResult: { outcome: 'failed', errorCode: result.errorCode }, lastVerdict: 'failed', failureReason: result.reason, stepDelta: result.attemptsMade };
344
631
  }
345
- await recordOutput(runId, node, ordinal, result.output, ctx.outputsByNode);
632
+ await recordOutput(runId, node, ordinal, result.attemptId, result.output, ctx.outputsByNode);
346
633
  const verdict = result.verdict;
347
634
  return {
348
635
  lastResult: { outcome: 'succeeded', ...(verdict ? { verdict } : {}) },
349
636
  ...(verdict ? { lastVerdict: verdict } : {}),
350
- stepDelta: 1,
637
+ stepDelta: result.attemptsMade,
351
638
  };
352
639
  }
353
640
  case 'invokeScript': {
@@ -363,7 +650,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
363
650
  });
364
651
  return { lastResult: { outcome: 'failed', errorCode: REVO_INPUT_MISSING }, lastVerdict: 'failed', stepDelta: 1 };
365
652
  }
366
- const scriptResult = await invokeScript(runId, decision, { taskId, title, base }, bindingByRef, stepKeyFor(node.id, ordinal), resolved.inputs);
653
+ const scriptResult = await invokeScript(runId, decision, { taskId, title, base, issueRef: ctx.issueRef }, bindingByRef, stepKeyFor(node.id, ordinal), resolved.inputs);
367
654
  // A blocked script (needsHuman) routes via revo.ScriptBlocked → a `blocked` terminal; a thrown
368
655
  // script routes via revo.ScriptFailed → a `failed` terminal (§6 catch). The lesson-bearing
369
656
  // pipeline_blocked is emitted inside invokeScript for the block path (parity with the old engine).
@@ -373,7 +660,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
373
660
  if (scriptResult.outcome === 'failed') {
374
661
  return { lastResult: { outcome: 'failed', errorCode: REVO_SCRIPT_FAILED }, lastVerdict: 'failed', stepDelta: 1 };
375
662
  }
376
- await recordOutput(runId, node, ordinal, scriptResult.pointer, ctx.outputsByNode);
663
+ await recordOutput(runId, node, ordinal, stepKeyFor(node.id, ordinal), scriptResult.pointer, ctx.outputsByNode);
377
664
  // A classifying script (pollPr, plan 0018) surfaces a DOMAIN verdict so the next `choice` can
378
665
  // route on it (§8). Scripts with a single fixed `next` (integrator/confirmMerge) carry none.
379
666
  const sv = scriptResult.verdict;
@@ -384,10 +671,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
384
671
  // Per-entry gate key (nodeId#ordinal) so a re-entered gate (e.g. a question gate looped in the
385
672
  // review phase) gets a DISTINCT inbox row instead of colliding on `runId|topic` (§3.2 audit).
386
673
  const ordinal = nextOrdinal(ctx.effectOrdinalByNode, decision.nodeId);
387
- const human = await awaitHuman(runId, topic, stepKeyFor(decision.nodeId, ordinal), `${decision.reason} approval`, {
388
- nodeId: decision.nodeId,
389
- outcomes: decision.outcomes,
390
- });
674
+ const human = await awaitHuman(runId, topic, stepKeyFor(decision.nodeId, ordinal), `${decision.reason} approval`, buildGateSummary(decision, ctx.outputsByNode, ctx.lastVerdict));
391
675
  const verdict = gateVerdict(human, decision.outcomes);
392
676
  return { lastResult: verdict ? { verdict } : {}, ...(verdict ? { lastVerdict: verdict } : {}), stepDelta: 0 };
393
677
  }
@@ -417,29 +701,200 @@ export function makeDataDrivenTask(runStepFn, deps) {
417
701
  // ── Effect executors ─────────────────────────────────────────────────────────
418
702
  /** invokeRole → dispatch via the existing runStep DBOS step; validate result vs resultSchema. */
419
703
  async function invokeRole(runId, decision, node, ctx, inputs, stepKey) {
704
+ const binding = resolveRoleBinding(ctx, decision);
705
+ // stepKey is ordinal-aware (0016 §4.1): distinct per loop iteration, stable across replay. The
706
+ // hydrated `inputs` (consumed upstream outputs) ride in stepInput → the runner renders them as a
707
+ // `## Inputs (from previous steps)` prompt section (build-context).
708
+ return invokeRoleAttempts({ runId, decision, node, ctx, inputs, stepKey, binding });
709
+ }
710
+ function resolveRoleBinding(ctx, decision) {
420
711
  const binding = ctx.bindingByRef.get(decision.roleRef);
421
- if (!binding) {
712
+ if (binding === undefined) {
422
713
  // A VALID template's caps resolve at run start; an unresolved roleRef is a fatal config gap.
423
714
  throw new Error(`CAPABILITY_UNRESOLVED: roleRef ${decision.roleRef} has no route binding`);
424
715
  }
425
- // stepKey is ordinal-aware (0016 §4.1): distinct per loop iteration, stable across replay. The
426
- // hydrated `inputs` (consumed upstream outputs) ride in stepInput → the runner renders them as a
427
- // `## Inputs (from previous steps)` prompt section (build-context).
428
- const stepInput = Object.keys(inputs).length > 0 ? { nodeId: decision.nodeId, inputs } : { nodeId: decision.nodeId };
429
- const result = await runStepFn(runId, binding.rowId, stepKey, stepInput, binding.resolvedRunnerId, ctx.executionProfile);
430
- // runStep converts a runner-process crash into a blocking attempt (needsHuman + verdict BLOCKER);
431
- // that is a domain failure of the node → route via §6 precedence as a result-invalid/failed effect.
716
+ return binding;
717
+ }
718
+ async function invokeRoleAttempts(input) {
719
+ const { runId, decision, node, ctx, inputs, stepKey, binding } = input;
720
+ const attemptIds = [];
721
+ for (let attemptNo = 1; attemptNo <= ctx.runnerRetryPolicy.maxAttempts; attemptNo++) {
722
+ const physicalAttempt = physicalAttemptFor(runId, stepKey, attemptNo);
723
+ attemptIds.push(physicalAttempt.attemptId);
724
+ const result = await runStepFn(runId, binding.rowId, stepKey, stepInputForAttempt(decision.nodeId, inputs, physicalAttempt), binding.resolvedRunnerId, ctx.executionProfile, physicalAttempt);
725
+ const needsHuman = await maybeHandleNeedsHumanRoleResult({
726
+ ...input,
727
+ attemptIds,
728
+ physicalAttempt,
729
+ result,
730
+ });
731
+ if (needsHuman === 'retry')
732
+ continue;
733
+ if (needsHuman)
734
+ return needsHuman;
735
+ const failed = roleValidationFailure(ctx.template, node, result, physicalAttempt);
736
+ if (failed)
737
+ return failed;
738
+ let output = result.output;
739
+ const hasProducedChange = producedChangeArtifact(output) !== undefined;
740
+ const shouldCaptureChange = nodeProducesChange(node) &&
741
+ !hasProducedChange &&
742
+ ctx.live &&
743
+ runnerProducesWorktreeChanges(binding.resolvedRunnerId);
744
+ if (shouldCaptureChange) {
745
+ const artifactRef = artifactRefFromResult(result);
746
+ const change = await captureChangeFn({
747
+ runId,
748
+ taskId: ctx.taskId,
749
+ title: ctx.title,
750
+ base: ctx.base,
751
+ nodeId: node.id,
752
+ attemptId: physicalAttempt.attemptId,
753
+ ...(ctx.issueRef ? { issueRef: ctx.issueRef } : {}),
754
+ ...(artifactRef ? { artifactRef } : {}),
755
+ });
756
+ output = attachProducedChange(output, change);
757
+ }
758
+ return {
759
+ failed: false,
760
+ verdict: domainVerdictOf(result),
761
+ output,
762
+ attemptId: physicalAttempt.attemptId,
763
+ attemptsMade: attemptNo,
764
+ };
765
+ }
766
+ throw new InterpretError(`runner retry loop for ${stepKey} exceeded maxAttempts`);
767
+ }
768
+ async function maybeHandleNeedsHumanRoleResult(input) {
769
+ const { result, node, physicalAttempt } = input;
432
770
  if (result.needsHuman) {
433
- return { failed: true, errorCode: REVO_RESULT_INVALID, reason: `${REVO_RESULT_INVALID}: node ${node.id} returned needsHuman instead of a valid result` };
771
+ // A `needsHuman` result is NOT a wiring fault it is a recoverable human-block. Route it to a
772
+ // visible `blocked` terminal (like a blocked SCRIPT), never a ResultInvalid abort (which permanently
773
+ // kills the run). TWO distinct things produce needsHuman; both block, but the human must see WHICH:
774
+ // - DELIBERATE: the agent self-reported `needsHuman:true` (the result-envelope contract). It carries
775
+ // the agent's own `output` + `lesson` — a genuine "I need a human decision".
776
+ // - TRANSIENT: runStep wraps a runner-process failure as `output.error === 'runner_failed'`.
777
+ const transient = transientRunnerFailure(result);
778
+ if (transient === undefined) {
779
+ const safeLesson = String(redactEventPayload(result.lesson ?? `agent ${node.id} reported needsHuman`));
780
+ return { blocked: true, reason: 'agent-needs-human', lesson: safeLesson, attemptsMade: physicalAttempt.attemptNo };
781
+ }
782
+ return handleTransientRoleResult({ ...input, transient });
434
783
  }
435
- if (!resultSatisfiesSchema(node, result)) {
436
- 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 : '')}` };
784
+ return undefined;
785
+ }
786
+ async function handleTransientRoleResult(input) {
787
+ const { runId, node, ctx, stepKey, attemptIds, physicalAttempt, transient } = input;
788
+ const policy = ctx.runnerRetryPolicy;
789
+ if (shouldRetryTransient(transient, physicalAttempt, policy)) {
790
+ const nextAttempt = physicalAttemptFor(runId, stepKey, physicalAttempt.attemptNo + 1);
791
+ await appendRunnerRetryScheduled({
792
+ runId,
793
+ taskId: ctx.taskId,
794
+ stepKey,
795
+ nodeId: node.id,
796
+ failedAttempt: physicalAttempt,
797
+ nextAttempt,
798
+ policy,
799
+ transient,
800
+ });
801
+ if (policy.backoffMs > 0)
802
+ await deps.sleep(policy.backoffMs);
803
+ return 'retry';
437
804
  }
438
- const verdictProblem = resultVerdictProblem(ctx.template, node, result);
439
- if (verdictProblem) {
440
- return { failed: true, errorCode: REVO_RESULT_INVALID, reason: verdictProblem };
805
+ const retry = runnerRetryBlockPayload({
806
+ transient,
807
+ attemptIds,
808
+ lastAttempt: physicalAttempt,
809
+ policy,
810
+ attemptsExhausted: transient.retryable && physicalAttempt.attemptNo >= policy.maxAttempts,
811
+ });
812
+ if (retry.attemptsExhausted) {
813
+ await appendRunnerRetryExhausted({
814
+ runId,
815
+ taskId: ctx.taskId,
816
+ stepKey,
817
+ nodeId: node.id,
818
+ retry,
819
+ idempotencyKey: physicalAttempt.attemptId,
820
+ });
441
821
  }
442
- return { failed: false, verdict: domainVerdictOf(result), output: result.output };
822
+ return {
823
+ blocked: true,
824
+ reason: retry.reason,
825
+ lesson: retry.lesson,
826
+ retry,
827
+ attemptsMade: physicalAttempt.attemptNo,
828
+ };
829
+ }
830
+ function shouldRetryTransient(transient, attempt, policy) {
831
+ return transient.retryable && attempt.attemptNo < policy.maxAttempts;
832
+ }
833
+ function roleValidationFailure(template, node, result, physicalAttempt) {
834
+ if (resultSatisfiesSchema(node, result)) {
835
+ const verdictProblem = resultVerdictProblem(template, node, result);
836
+ if (verdictProblem)
837
+ return invalidRoleResult(verdictProblem, physicalAttempt);
838
+ return undefined;
839
+ }
840
+ return invalidRoleResult(`${REVO_RESULT_INVALID}: node ${node.id} result did not satisfy resultSchema ${String('resultSchema' in node ? node.resultSchema : '')}`, physicalAttempt);
841
+ }
842
+ function runnerRetryBlockPayload(input) {
843
+ const { transient, attemptIds, lastAttempt, policy, attemptsExhausted } = input;
844
+ const reason = runnerBlockReason(transient);
845
+ const lesson = runnerBlockLesson(transient);
846
+ return {
847
+ attemptsExhausted,
848
+ attemptsMade: lastAttempt.attemptNo,
849
+ maxAttempts: policy.maxAttempts,
850
+ attemptIds: [...attemptIds],
851
+ lastAttemptId: lastAttempt.attemptId,
852
+ reason,
853
+ lesson,
854
+ ...optionalFailureKind(transient.failureKind),
855
+ transientKind: transient.transientKind,
856
+ ...optionalTiming(transient.timing),
857
+ };
858
+ }
859
+ async function appendRunnerRetryScheduled(input) {
860
+ const { runId, taskId, stepKey, nodeId, failedAttempt, nextAttempt, policy, transient } = input;
861
+ await appendEvent({
862
+ runId,
863
+ taskId,
864
+ stepId: '',
865
+ stepKey,
866
+ type: 'runner_retry_scheduled',
867
+ idempotencyKey: failedAttempt.attemptId,
868
+ payload: {
869
+ nodeId,
870
+ failedAttemptNo: failedAttempt.attemptNo,
871
+ failedAttemptId: failedAttempt.attemptId,
872
+ nextAttemptNo: nextAttempt.attemptNo,
873
+ nextAttemptId: nextAttempt.attemptId,
874
+ maxAttempts: policy.maxAttempts,
875
+ backoffMs: policy.backoffMs,
876
+ reason: runnerBlockReason(transient),
877
+ lesson: runnerBlockLesson(transient),
878
+ ...optionalFailureKind(transient.failureKind),
879
+ transientKind: transient.transientKind,
880
+ ...optionalTiming(transient.timing),
881
+ },
882
+ });
883
+ }
884
+ async function appendRunnerRetryExhausted(input) {
885
+ const { runId, taskId, stepKey, nodeId, retry, idempotencyKey } = input;
886
+ await appendEvent({
887
+ runId,
888
+ taskId,
889
+ stepId: '',
890
+ stepKey,
891
+ type: 'runner_retry_exhausted',
892
+ idempotencyKey,
893
+ payload: {
894
+ nodeId,
895
+ ...retry,
896
+ },
897
+ });
443
898
  }
444
899
  /**
445
900
  * invokeScript → the built-in system SCRIPT library. The only built-in v1 script is the integrator
@@ -461,7 +916,20 @@ export function makeDataDrivenTask(runStepFn, deps) {
461
916
  const binding = bindingByRef.get(decision.scriptRef) ?? bindingByRef.get('script:integrator');
462
917
  // respondThreads consumes `triage` (plan 0018) — ride the hydrated input on the integrator input so
463
918
  // the live script can reply/resolve the triaged threads without a live Revisium read.
464
- const integratorInput = { runId, taskId: ctx.taskId, title: ctx.title, base: ctx.base, ...(inputs.triage !== undefined ? { triage: inputs.triage } : {}) };
919
+ const change = producedChangeFromInputs(inputs);
920
+ const mergeReadiness = mergeReadinessFromInputs(inputs);
921
+ const issueRef = ctx.issueRef;
922
+ const changeForIntegrator = change ? changeWithRunIssueRef(change, issueRef) : undefined;
923
+ const integratorInput = {
924
+ runId,
925
+ taskId: ctx.taskId,
926
+ title: ctx.title,
927
+ base: ctx.base,
928
+ ...(issueRef ? { issueRef } : {}),
929
+ ...(changeForIntegrator ? { change: changeForIntegrator } : {}),
930
+ ...(inputs.triage === undefined ? {} : { triage: inputs.triage }),
931
+ ...(mergeReadiness ? { mergeReadiness } : {}),
932
+ };
465
933
  // A script node whose resolved runner mechanically performs the merge uses the REAL script;
466
934
  // otherwise the pure stub (zero git/gh). Absent a binding (template-only script), default to stub.
467
935
  const useReal = !!binding && runnerUsesRealIntegrator(binding.resolvedRunnerId);
@@ -513,9 +981,21 @@ export function makeDataDrivenTask(runStepFn, deps) {
513
981
  stepId: '',
514
982
  stepKey,
515
983
  type: 'merge_confirmed',
516
- payload: { prNumber: merged.prNumber, prUrl: merged.prUrl },
984
+ payload: {
985
+ prNumber: merged.prNumber,
986
+ prUrl: merged.prUrl,
987
+ ...(merged.issueRef ? { issueRef: merged.issueRef } : {}),
988
+ },
517
989
  });
518
- return { outcome: 'ok', pointer: { merged: true, prNumber: merged.prNumber, prUrl: merged.prUrl } };
990
+ return {
991
+ outcome: 'ok',
992
+ pointer: {
993
+ merged: true,
994
+ prNumber: merged.prNumber,
995
+ prUrl: merged.prUrl,
996
+ ...(merged.issueRef ? { issueRef: merged.issueRef } : {}),
997
+ },
998
+ };
519
999
  }
520
1000
  if (isPollPr) {
521
1001
  // pollPr CLASSIFIES the feedback: its verdict (review_changes/ci_changes/clean) routes the prRouter
@@ -527,7 +1007,15 @@ export function makeDataDrivenTask(runStepFn, deps) {
527
1007
  stepId: '',
528
1008
  stepKey,
529
1009
  type: 'pr_polled',
530
- payload: { prNumber: feedback.prNumber, verdict: feedback.verdict, ciFailures: feedback.ciFailures.length, reviewThreads: feedback.reviewThreads.length },
1010
+ payload: {
1011
+ prNumber: feedback.prNumber,
1012
+ headSha: feedback.headSha,
1013
+ verdict: feedback.verdict,
1014
+ evidence: feedback.evidence,
1015
+ ciFailures: feedback.ciFailures.length,
1016
+ reviewThreads: feedback.reviewThreads.length,
1017
+ ...(feedback.issueRef ? { issueRef: feedback.issueRef } : {}),
1018
+ },
531
1019
  });
532
1020
  return { outcome: 'ok', pointer: feedback, verdict: feedback.verdict };
533
1021
  }
@@ -550,9 +1038,26 @@ export function makeDataDrivenTask(runStepFn, deps) {
550
1038
  stepId: '',
551
1039
  stepKey,
552
1040
  type: 'integrate_succeeded',
553
- payload: { prUrl: integrated.prUrl, branch: integrated.branch, prNumber: integrated.prNumber },
1041
+ payload: {
1042
+ prUrl: integrated.prUrl,
1043
+ branch: integrated.branch,
1044
+ prNumber: integrated.prNumber,
1045
+ headSha: integrated.headSha,
1046
+ status: integrated.status,
1047
+ ...(integrated.issueRef ? { issueRef: integrated.issueRef } : {}),
1048
+ },
554
1049
  });
555
- return { outcome: 'ok', pointer: { prUrl: integrated.prUrl, branch: integrated.branch, prNumber: integrated.prNumber } };
1050
+ return {
1051
+ outcome: 'ok',
1052
+ pointer: {
1053
+ prUrl: integrated.prUrl,
1054
+ branch: integrated.branch,
1055
+ prNumber: integrated.prNumber,
1056
+ headSha: integrated.headSha,
1057
+ status: integrated.status,
1058
+ ...(integrated.issueRef ? { issueRef: integrated.issueRef } : {}),
1059
+ },
1060
+ };
556
1061
  }
557
1062
  /** Terminal: finish the run in Revisium per the core's terminal status. */
558
1063
  async function finish(runId, status, verdict, steps, failureReason = '') {
@@ -581,14 +1086,14 @@ export function makeDataDrivenTask(runStepFn, deps) {
581
1086
  * boundary) + marks the Revisium run-row blocked, then returns a `blocked` result WITHOUT entering the
582
1087
  * graph. Mirrors the old engine's `blockPipeline({ reason })`.
583
1088
  */
584
- async function blockWithLesson(runId, taskId, reason, lesson, steps) {
1089
+ async function blockWithLesson(runId, taskId, reason, lesson, steps, retry) {
585
1090
  await appendEvent({
586
1091
  runId,
587
1092
  taskId,
588
1093
  stepId: '',
589
1094
  stepKey: 'pipeline',
590
1095
  type: 'pipeline_blocked',
591
- payload: { reason, lesson },
1096
+ payload: pipelineBlockedPayload(reason, lesson, retry),
592
1097
  });
593
1098
  await blockRun(runId, { actor: 'pipeline', source: `data-driven-${reason}`, reason });
594
1099
  return { runId, status: 'blocked', verdict: 'blocked', steps };