@revisium/orchestrator 0.1.0-alpha.9 → 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 (103) hide show
  1. package/README.md +143 -157
  2. package/control-plane/default-playbook/catalog/pipelines.json +217 -8
  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/control-plane/bootstrap.js +14 -1
  19. package/dist/control-plane/bootstrap.js.map +1 -1
  20. package/dist/control-plane/client-transport.js +1 -1
  21. package/dist/control-plane/client-transport.js.map +1 -1
  22. package/dist/control-plane/default-playbook-policy.js +593 -0
  23. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  24. package/dist/control-plane/seed-default-playbook.js +77 -8
  25. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  26. package/dist/e2e/kit/agents.js +15 -21
  27. package/dist/e2e/kit/agents.js.map +1 -1
  28. package/dist/e2e/kit/assertions.js +12 -8
  29. package/dist/e2e/kit/assertions.js.map +1 -1
  30. package/dist/e2e/kit/fake-integrator.js +11 -2
  31. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  32. package/dist/e2e/kit/git-target-repo.js +1 -1
  33. package/dist/engine/dbos.service.js +3 -0
  34. package/dist/engine/dbos.service.js.map +1 -1
  35. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  36. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  37. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  38. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  39. package/dist/mcp/mcp-capabilities.js +14 -1
  40. package/dist/mcp/mcp-capabilities.js.map +1 -1
  41. package/dist/mcp/mcp-facade.service.js +306 -11
  42. package/dist/mcp/mcp-facade.service.js.map +1 -1
  43. package/dist/mcp/mcp-tools.js +41 -14
  44. package/dist/mcp/mcp-tools.js.map +1 -1
  45. package/dist/observability/activity-signal.js +107 -0
  46. package/dist/observability/activity-signal.js.map +1 -0
  47. package/dist/observability/index.js +1 -0
  48. package/dist/observability/index.js.map +1 -1
  49. package/dist/pipeline/data-driven-task.workflow.js +482 -40
  50. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  51. package/dist/pipeline/pipeline.service.js +195 -101
  52. package/dist/pipeline/pipeline.service.js.map +1 -1
  53. package/dist/pipeline/route-contract.js +4 -3
  54. package/dist/pipeline/route-contract.js.map +1 -1
  55. package/dist/pipeline-core/kit/fixtures.js +72 -21
  56. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  57. package/dist/playbook/import-mapper.js +8 -1
  58. package/dist/playbook/import-mapper.js.map +1 -1
  59. package/dist/poller/pr-readiness-core.js +299 -28
  60. package/dist/poller/pr-readiness-core.js.map +1 -1
  61. package/dist/revisium/playbooks.service.js +17 -2
  62. package/dist/revisium/playbooks.service.js.map +1 -1
  63. package/dist/revisium/run.service.js +1 -0
  64. package/dist/revisium/run.service.js.map +1 -1
  65. package/dist/run/append-event.js +8 -5
  66. package/dist/run/append-event.js.map +1 -1
  67. package/dist/run/create-run.js +5 -1
  68. package/dist/run/create-run.js.map +1 -1
  69. package/dist/run/inspect-run.js +10 -3
  70. package/dist/run/inspect-run.js.map +1 -1
  71. package/dist/run/issue-ref.js +88 -0
  72. package/dist/run/issue-ref.js.map +1 -0
  73. package/dist/runners/claude-code.service.js +2 -2
  74. package/dist/runners/claude-code.service.js.map +1 -1
  75. package/dist/runners/codex.service.js +2 -2
  76. package/dist/runners/codex.service.js.map +1 -1
  77. package/dist/runners/integrator-branch-naming.js +13 -8
  78. package/dist/runners/integrator-branch-naming.js.map +1 -1
  79. package/dist/runners/integrator.js +254 -89
  80. package/dist/runners/integrator.js.map +1 -1
  81. package/dist/runners/worktree.service.js +2 -2
  82. package/dist/runners/worktree.service.js.map +1 -1
  83. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  84. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  85. package/dist/task-control-plane/run-watch.service.js +234 -3
  86. package/dist/task-control-plane/run-watch.service.js.map +1 -1
  87. package/dist/task-control-plane/task-control-plane-api.service.js +272 -25
  88. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  89. package/dist/worker/artifact-store.js +21 -3
  90. package/dist/worker/artifact-store.js.map +1 -1
  91. package/dist/worker/build-context.js +19 -3
  92. package/dist/worker/build-context.js.map +1 -1
  93. package/dist/worker/claude-code-runner.js +76 -15
  94. package/dist/worker/claude-code-runner.js.map +1 -1
  95. package/dist/worker/codex-runner.js +40 -13
  96. package/dist/worker/codex-runner.js.map +1 -1
  97. package/dist/worker/process-executor.js +149 -20
  98. package/dist/worker/process-executor.js.map +1 -1
  99. package/dist/worker/runner-common.js +29 -0
  100. package/dist/worker/runner-common.js.map +1 -1
  101. package/dist/worker/runner.js +16 -1
  102. package/dist/worker/runner.js.map +1 -1
  103. package/package.json +1 -1
@@ -31,8 +31,61 @@ import { step as coreStep, initialState, validateTemplate, InterpretError, } fro
31
31
  import { runnerNeedsLivePreflight, runnerUsesRealIntegrator } from './route-contract.js';
32
32
  import { redactEventPayload } from '../run/append-event.js';
33
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';
34
37
  export const RUN_PROGRESS_EVENT_KEY = 'run-progress';
35
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
+ }
36
89
  /**
37
90
  * Reserved engine error codes (matched only by a node's `catch`, §3/§6).
38
91
  *
@@ -48,6 +101,31 @@ const REVO_SCRIPT_FAILED = 'revo.ScriptFailed';
48
101
  const REVO_SCRIPT_BLOCKED = 'revo.ScriptBlocked';
49
102
  const REVO_RESULT_INVALID = 'revo.ResultInvalid';
50
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
+ }
51
129
  /** Per-node execution stepKey: the bare nodeId on the first entry (stable ids for existing tests), an
52
130
  * ordinal-suffixed key on loop re-entries so attempts/events/outputs are distinct per iteration (0016
53
131
  * §4.1 — fixes the latent 0015 stepKey-reuse collision). */
@@ -64,6 +142,125 @@ function nextOrdinal(byNode, nodeId) {
64
142
  function isRecord(value) {
65
143
  return value !== null && typeof value === 'object' && !Array.isArray(value);
66
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
+ }
67
264
  /** Extract the DOMAIN verdict from the only supported source: top-level AttemptResult.verdict. */
68
265
  function domainVerdictOf(result) {
69
266
  if (typeof result.verdict === 'string' && result.verdict.trim().length > 0) {
@@ -205,7 +402,7 @@ export function buildGateSummary(decision, outputsByNode, lastVerdict) {
205
402
  * production and tests. Returns a plain async function with the workflow signature.
206
403
  */
207
404
  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;
405
+ const { appendEvent, appendRunOutput, awaitHuman, completeRun, failRun, blockRun, loadRunTaskContext, integrateFn, runStub, confirmMergeFn, runConfirmStub, pollPrFn, runPollStub, respondThreadsFn, runRespondStub, captureChangeFn, preflightFn, createWorktreeFn, releaseWorktreeFn } = deps;
209
406
  /** Resolve a node's `consumes` from the workflow-local output accumulator — NOT live Revisium reads
210
407
  * (0016 §6 / consensus M4: a live read on replay can see rows written past the replay point). */
211
408
  function resolveConsumes(node, outputsByNode) {
@@ -240,7 +437,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
240
437
  }
241
438
  /** Record a node's produced output to the workflow-local accumulator + Revisium (when `produces`). */
242
439
  // (gate-summary helpers live at module scope below — they are pure over outputsByNode.)
243
- async function recordOutput(runId, node, ordinal, output, outputsByNode) {
440
+ async function recordOutput(runId, node, ordinal, attemptId, output, outputsByNode) {
244
441
  if (!('produces' in node) || !node.produces)
245
442
  return;
246
443
  const row = {
@@ -250,7 +447,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
250
447
  name: node.produces.name,
251
448
  schemaRef: ('resultSchema' in node && node.resultSchema) || '',
252
449
  payload: output,
253
- attemptId: stepKeyFor(node.id, ordinal),
450
+ attemptId,
254
451
  };
255
452
  const list = outputsByNode.get(node.id) ?? [];
256
453
  list.push(row);
@@ -283,7 +480,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
283
480
  if (diagnostics.length > 0) {
284
481
  throw new Error(`PINNED_TEMPLATE_INVALID: ${template.pipelineId} — ${diagnostics.map((d) => d.code).join(', ')}`);
285
482
  }
286
- const { taskId, title, base } = await loadRunTaskContext(runId);
483
+ const { taskId, title, base, issueRef } = await loadRunTaskContext(runId);
287
484
  // B5/B7 — live preflight: one memoized DBOS step, evaluated exactly once BEFORE the graph runs.
288
485
  // Skipped entirely when every selected binding resolves to a stub/script runner (mirrors the old
289
486
  // engine's gate). A `needsHuman` preflight blocks the run (clean/base invariant unmet) and surfaces
@@ -292,7 +489,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
292
489
  const live = route.roleBindings.some((b) => runnerNeedsLivePreflight(b.resolvedRunnerId));
293
490
  if (live) {
294
491
  // 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.
492
+ // fetch + clean/base-availability checks protect the user's base repo. Ordering is load-bearing.
296
493
  const pf = await preflightFn(taskId, base);
297
494
  if ('needsHuman' in pf) {
298
495
  return await blockWithLesson(runId, taskId, 'preflight', pf.lesson, 0);
@@ -310,9 +507,9 @@ export function makeDataDrivenTask(runStepFn, deps) {
310
507
  let result;
311
508
  try {
312
509
  if (live) {
313
- await createWorktreeFn(runId, taskId, title, base);
510
+ await createWorktreeFn(runId, taskId, title, base, issueRef);
314
511
  }
315
- result = await runGraph(runId, opts, taskId, title, base);
512
+ result = await runGraph(runId, opts, taskId, title, base, issueRef, live);
316
513
  return result;
317
514
  }
318
515
  finally {
@@ -327,8 +524,8 @@ export function makeDataDrivenTask(runStepFn, deps) {
327
524
  }
328
525
  }
329
526
  /** 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;
527
+ async function runGraph(runId, opts, taskId, title, base, issueRef, live) {
528
+ const { route, template, runnerRetryPolicy } = opts;
332
529
  // Capability resolution map (GENERIC — no role-ids in the engine): roleRef/scriptRef → route binding.
333
530
  // The route's bindings are authoritative for runner dispatch (and durable on recovery via the DBOS
334
531
  // workflow input). A `role:<id>` handle resolves to the binding whose roleId matches `<id>`. A
@@ -363,17 +560,25 @@ export function makeDataDrivenTask(runStepFn, deps) {
363
560
  return await finish(runId, decision.status, lastVerdict, stepCount, lastFailureReason);
364
561
  }
365
562
  const eff = await applyDecision(decision, {
366
- runId, template, bindingByRef, executionProfile, taskId, title, base,
367
- effectOrdinalByNode, outputsByNode,
563
+ runId, template, bindingByRef, executionProfile, taskId, title, base, issueRef,
564
+ effectOrdinalByNode, outputsByNode, runnerRetryPolicy,
565
+ live,
368
566
  // The verdict from the PRIOR effect (the reviewer/poller that routed into this node). At an
369
567
  // awaitGate this is the routing verdict that opened the gate → D3's default reviewerVerdict.
370
568
  lastVerdict,
371
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
+ }
372
578
  lastResult = eff.lastResult;
373
579
  if (eff.lastVerdict !== undefined)
374
580
  lastVerdict = eff.lastVerdict;
375
581
  lastFailureReason = eff.failureReason ?? '';
376
- stepCount += eff.stepDelta;
377
582
  }
378
583
  throw new InterpretError(`data-driven ${template.pipelineId} did not terminate within ${MAX_STEPS} steps (template loop bug)`);
379
584
  }
@@ -401,19 +606,35 @@ export function makeDataDrivenTask(runStepFn, deps) {
401
606
  return { lastResult: { outcome: 'failed', errorCode: REVO_INPUT_MISSING }, lastVerdict: 'failed', stepDelta: 1 };
402
607
  }
403
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
+ }
404
624
  if (result.failed) {
405
625
  await appendEvent({
406
626
  runId, taskId, stepId: '', stepKey, type: 'step_failed',
627
+ idempotencyKey: result.attemptId,
407
628
  payload: { nodeId: node.id, error: result.errorCode },
408
629
  });
409
- 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 };
410
631
  }
411
- await recordOutput(runId, node, ordinal, result.output, ctx.outputsByNode);
632
+ await recordOutput(runId, node, ordinal, result.attemptId, result.output, ctx.outputsByNode);
412
633
  const verdict = result.verdict;
413
634
  return {
414
635
  lastResult: { outcome: 'succeeded', ...(verdict ? { verdict } : {}) },
415
636
  ...(verdict ? { lastVerdict: verdict } : {}),
416
- stepDelta: 1,
637
+ stepDelta: result.attemptsMade,
417
638
  };
418
639
  }
419
640
  case 'invokeScript': {
@@ -429,7 +650,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
429
650
  });
430
651
  return { lastResult: { outcome: 'failed', errorCode: REVO_INPUT_MISSING }, lastVerdict: 'failed', stepDelta: 1 };
431
652
  }
432
- 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);
433
654
  // A blocked script (needsHuman) routes via revo.ScriptBlocked → a `blocked` terminal; a thrown
434
655
  // script routes via revo.ScriptFailed → a `failed` terminal (§6 catch). The lesson-bearing
435
656
  // pipeline_blocked is emitted inside invokeScript for the block path (parity with the old engine).
@@ -439,7 +660,7 @@ export function makeDataDrivenTask(runStepFn, deps) {
439
660
  if (scriptResult.outcome === 'failed') {
440
661
  return { lastResult: { outcome: 'failed', errorCode: REVO_SCRIPT_FAILED }, lastVerdict: 'failed', stepDelta: 1 };
441
662
  }
442
- await recordOutput(runId, node, ordinal, scriptResult.pointer, ctx.outputsByNode);
663
+ await recordOutput(runId, node, ordinal, stepKeyFor(node.id, ordinal), scriptResult.pointer, ctx.outputsByNode);
443
664
  // A classifying script (pollPr, plan 0018) surfaces a DOMAIN verdict so the next `choice` can
444
665
  // route on it (§8). Scripts with a single fixed `next` (integrator/confirmMerge) carry none.
445
666
  const sv = scriptResult.verdict;
@@ -480,29 +701,200 @@ export function makeDataDrivenTask(runStepFn, deps) {
480
701
  // ── Effect executors ─────────────────────────────────────────────────────────
481
702
  /** invokeRole → dispatch via the existing runStep DBOS step; validate result vs resultSchema. */
482
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) {
483
711
  const binding = ctx.bindingByRef.get(decision.roleRef);
484
- if (!binding) {
712
+ if (binding === undefined) {
485
713
  // A VALID template's caps resolve at run start; an unresolved roleRef is a fatal config gap.
486
714
  throw new Error(`CAPABILITY_UNRESOLVED: roleRef ${decision.roleRef} has no route binding`);
487
715
  }
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.
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;
495
770
  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` };
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 });
497
783
  }
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 : '')}` };
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';
500
804
  }
501
- const verdictProblem = resultVerdictProblem(ctx.template, node, result);
502
- if (verdictProblem) {
503
- 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
+ });
821
+ }
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;
504
839
  }
505
- return { failed: false, verdict: domainVerdictOf(result), output: result.output };
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
+ });
506
898
  }
507
899
  /**
508
900
  * invokeScript → the built-in system SCRIPT library. The only built-in v1 script is the integrator
@@ -524,7 +916,20 @@ export function makeDataDrivenTask(runStepFn, deps) {
524
916
  const binding = bindingByRef.get(decision.scriptRef) ?? bindingByRef.get('script:integrator');
525
917
  // respondThreads consumes `triage` (plan 0018) — ride the hydrated input on the integrator input so
526
918
  // 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 } : {}) };
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
+ };
528
933
  // A script node whose resolved runner mechanically performs the merge uses the REAL script;
529
934
  // otherwise the pure stub (zero git/gh). Absent a binding (template-only script), default to stub.
530
935
  const useReal = !!binding && runnerUsesRealIntegrator(binding.resolvedRunnerId);
@@ -576,9 +981,21 @@ export function makeDataDrivenTask(runStepFn, deps) {
576
981
  stepId: '',
577
982
  stepKey,
578
983
  type: 'merge_confirmed',
579
- payload: { prNumber: merged.prNumber, prUrl: merged.prUrl },
984
+ payload: {
985
+ prNumber: merged.prNumber,
986
+ prUrl: merged.prUrl,
987
+ ...(merged.issueRef ? { issueRef: merged.issueRef } : {}),
988
+ },
580
989
  });
581
- 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
+ };
582
999
  }
583
1000
  if (isPollPr) {
584
1001
  // pollPr CLASSIFIES the feedback: its verdict (review_changes/ci_changes/clean) routes the prRouter
@@ -590,7 +1007,15 @@ export function makeDataDrivenTask(runStepFn, deps) {
590
1007
  stepId: '',
591
1008
  stepKey,
592
1009
  type: 'pr_polled',
593
- 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
+ },
594
1019
  });
595
1020
  return { outcome: 'ok', pointer: feedback, verdict: feedback.verdict };
596
1021
  }
@@ -613,9 +1038,26 @@ export function makeDataDrivenTask(runStepFn, deps) {
613
1038
  stepId: '',
614
1039
  stepKey,
615
1040
  type: 'integrate_succeeded',
616
- 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
+ },
617
1049
  });
618
- 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
+ };
619
1061
  }
620
1062
  /** Terminal: finish the run in Revisium per the core's terminal status. */
621
1063
  async function finish(runId, status, verdict, steps, failureReason = '') {
@@ -644,14 +1086,14 @@ export function makeDataDrivenTask(runStepFn, deps) {
644
1086
  * boundary) + marks the Revisium run-row blocked, then returns a `blocked` result WITHOUT entering the
645
1087
  * graph. Mirrors the old engine's `blockPipeline({ reason })`.
646
1088
  */
647
- async function blockWithLesson(runId, taskId, reason, lesson, steps) {
1089
+ async function blockWithLesson(runId, taskId, reason, lesson, steps, retry) {
648
1090
  await appendEvent({
649
1091
  runId,
650
1092
  taskId,
651
1093
  stepId: '',
652
1094
  stepKey: 'pipeline',
653
1095
  type: 'pipeline_blocked',
654
- payload: { reason, lesson },
1096
+ payload: pipelineBlockedPayload(reason, lesson, retry),
655
1097
  });
656
1098
  await blockRun(runId, { actor: 'pipeline', source: `data-driven-${reason}`, reason });
657
1099
  return { runId, status: 'blocked', verdict: 'blocked', steps };