@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.
- package/README.md +210 -146
- package/control-plane/bootstrap.config.json +43 -1
- package/control-plane/default-playbook/catalog/pipelines.json +1157 -10
- package/control-plane/default-playbook/catalog/roles.json +54 -0
- package/control-plane/default-playbook/package.json +1 -1
- package/control-plane/default-playbook/prompts/developer.md +1 -0
- package/control-plane/default-playbook/prompts/triager.md +3 -0
- package/dist/api/graphql-api/graphql-ws/pubsub.module.js +0 -1
- package/dist/api/graphql-api/graphql-ws/pubsub.module.js.map +1 -1
- package/dist/api/graphql-api/graphql-ws/run-progress-subscription-poller.service.js +0 -1
- package/dist/api/graphql-api/graphql-ws/run-progress-subscription-poller.service.js.map +1 -1
- package/dist/api/graphql-api/graphql-ws/subscription-bridge.service.js +0 -1
- package/dist/api/graphql-api/graphql-ws/subscription-bridge.service.js.map +1 -1
- package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
- package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
- package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
- package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
- package/dist/api/graphql-api/pr/model/pr-readiness.model.js +26 -0
- package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
- package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
- package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
- package/dist/api/graphql-api/runs/model/run.model.js +6 -0
- package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
- package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
- package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
- package/dist/cli/commands/doctor-report.js +0 -18
- package/dist/cli/commands/doctor-report.js.map +1 -1
- package/dist/cli/commands/lifecycle.js +3 -71
- package/dist/cli/commands/lifecycle.js.map +1 -1
- package/dist/cli/commands/mcp.js +0 -14
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/process-tree.js +4 -26
- package/dist/cli/commands/process-tree.js.map +1 -1
- package/dist/cli/commands/revisium-helpers.js +0 -6
- package/dist/cli/commands/revisium-helpers.js.map +1 -1
- package/dist/cli/commands/rogue-reaper.js +0 -34
- package/dist/cli/commands/rogue-reaper.js.map +1 -1
- package/dist/cli/index.js +0 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/program.js +0 -5
- package/dist/cli/program.js.map +1 -1
- package/dist/config.js +0 -25
- package/dist/config.js.map +1 -1
- package/dist/control-plane/bootstrap.js +12 -27
- package/dist/control-plane/bootstrap.js.map +1 -1
- package/dist/control-plane/change-notifications.js +0 -1
- package/dist/control-plane/change-notifications.js.map +1 -1
- package/dist/control-plane/client-transport.js +1 -12
- package/dist/control-plane/client-transport.js.map +1 -1
- package/dist/control-plane/default-playbook-policy.js +593 -0
- package/dist/control-plane/default-playbook-policy.js.map +1 -0
- package/dist/control-plane/definitions.js +8 -17
- package/dist/control-plane/definitions.js.map +1 -1
- package/dist/control-plane/inbox.js +2 -120
- package/dist/control-plane/inbox.js.map +1 -1
- package/dist/control-plane/resolve-cwd.js +0 -61
- package/dist/control-plane/resolve-cwd.js.map +1 -1
- package/dist/control-plane/schema-migration.js +0 -9
- package/dist/control-plane/schema-migration.js.map +1 -1
- package/dist/control-plane/seed-default-playbook.js +50 -46
- package/dist/control-plane/seed-default-playbook.js.map +1 -1
- package/dist/control-plane/steps.js +0 -15
- package/dist/control-plane/steps.js.map +1 -1
- package/dist/e2e/kit/agents.js +15 -21
- package/dist/e2e/kit/agents.js.map +1 -1
- package/dist/e2e/kit/assertions.js +12 -8
- package/dist/e2e/kit/assertions.js.map +1 -1
- package/dist/e2e/kit/fake-integrator.js +11 -2
- package/dist/e2e/kit/fake-integrator.js.map +1 -1
- package/dist/e2e/kit/git-target-repo.js +1 -1
- package/dist/engine/dbos.service.js +12 -179
- package/dist/engine/dbos.service.js.map +1 -1
- package/dist/engine/ensure-postgres.js +0 -48
- package/dist/engine/ensure-postgres.js.map +1 -1
- package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
- package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
- package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
- package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
- package/dist/host/daemon.js +2 -21
- package/dist/host/daemon.js.map +1 -1
- package/dist/host/dbos-identity.js +0 -31
- package/dist/host/dbos-identity.js.map +1 -1
- package/dist/host/ensure-host.js +1 -37
- package/dist/host/ensure-host.js.map +1 -1
- package/dist/host/ensure-revisium.js +1 -135
- package/dist/host/ensure-revisium.js.map +1 -1
- package/dist/host/host-runtime.js +0 -28
- package/dist/host/host-runtime.js.map +1 -1
- package/dist/host/host.lifecycle.js +0 -27
- package/dist/host/host.lifecycle.js.map +1 -1
- package/dist/host/queue-ownership.js +0 -42
- package/dist/host/queue-ownership.js.map +1 -1
- package/dist/host/queue-poller-census.js +1 -28
- package/dist/host/queue-poller-census.js.map +1 -1
- package/dist/http/graphql-host.module.js +0 -3
- package/dist/http/graphql-host.module.js.map +1 -1
- package/dist/mcp/mcp-capabilities.js +23 -5
- package/dist/mcp/mcp-capabilities.js.map +1 -1
- package/dist/mcp/mcp-facade.service.js +335 -22
- package/dist/mcp/mcp-facade.service.js.map +1 -1
- package/dist/mcp/mcp-http.service.js +0 -18
- package/dist/mcp/mcp-http.service.js.map +1 -1
- package/dist/mcp/mcp-tools.js +41 -36
- package/dist/mcp/mcp-tools.js.map +1 -1
- package/dist/observability/activity-signal.js +103 -0
- package/dist/observability/activity-signal.js.map +1 -0
- package/dist/observability/agent-activity-reporter.js +0 -4
- package/dist/observability/agent-activity-reporter.js.map +1 -1
- package/dist/observability/index.js +1 -0
- package/dist/observability/index.js.map +1 -1
- package/dist/pipeline/await-human.js +0 -40
- package/dist/pipeline/await-human.js.map +1 -1
- package/dist/pipeline/data-driven-task.workflow.js +563 -251
- package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
- package/dist/pipeline/data-driven-template.js +0 -25
- package/dist/pipeline/data-driven-template.js.map +1 -1
- package/dist/pipeline/pipeline.module.js +0 -12
- package/dist/pipeline/pipeline.module.js.map +1 -1
- package/dist/pipeline/pipeline.service.js +194 -189
- package/dist/pipeline/pipeline.service.js.map +1 -1
- package/dist/pipeline/route-contract.js +4 -3
- package/dist/pipeline/route-contract.js.map +1 -1
- package/dist/pipeline-core/index.js +1 -13
- package/dist/pipeline-core/index.js.map +1 -1
- package/dist/pipeline-core/interpret.js +21 -94
- package/dist/pipeline-core/interpret.js.map +1 -1
- package/dist/pipeline-core/kit/assertions.js +0 -19
- package/dist/pipeline-core/kit/assertions.js.map +1 -1
- package/dist/pipeline-core/kit/builders.js +1 -21
- package/dist/pipeline-core/kit/builders.js.map +1 -1
- package/dist/pipeline-core/kit/drive.js +1 -31
- package/dist/pipeline-core/kit/drive.js.map +1 -1
- package/dist/pipeline-core/kit/fixtures.js +78 -101
- package/dist/pipeline-core/kit/fixtures.js.map +1 -1
- package/dist/pipeline-core/kit/index.js +0 -8
- package/dist/pipeline-core/kit/index.js.map +1 -1
- package/dist/pipeline-core/types.js +13 -52
- package/dist/pipeline-core/types.js.map +1 -1
- package/dist/pipeline-core/validate-capability.js +0 -3
- package/dist/pipeline-core/validate-capability.js.map +1 -1
- package/dist/pipeline-core/validate-conflicts.js +0 -9
- package/dist/pipeline-core/validate-conflicts.js.map +1 -1
- package/dist/pipeline-core/validate-dataflow.js +0 -17
- package/dist/pipeline-core/validate-dataflow.js.map +1 -1
- package/dist/pipeline-core/validate-diff.js +3 -36
- package/dist/pipeline-core/validate-diff.js.map +1 -1
- package/dist/pipeline-core/validate-edges.js +0 -6
- package/dist/pipeline-core/validate-edges.js.map +1 -1
- package/dist/pipeline-core/validate-graph.js +0 -11
- package/dist/pipeline-core/validate-graph.js.map +1 -1
- package/dist/pipeline-core/validate-loops.js +37 -44
- package/dist/pipeline-core/validate-loops.js.map +1 -1
- package/dist/pipeline-core/validate-parallel.js +9 -15
- package/dist/pipeline-core/validate-parallel.js.map +1 -1
- package/dist/pipeline-core/validate-sink.js +0 -7
- package/dist/pipeline-core/validate-sink.js.map +1 -1
- package/dist/pipeline-core/validate-topology.js +1 -20
- package/dist/pipeline-core/validate-topology.js.map +1 -1
- package/dist/pipeline-core/validate-verdict.js +52 -7
- package/dist/pipeline-core/validate-verdict.js.map +1 -1
- package/dist/pipeline-core/validate.js +5 -29
- package/dist/pipeline-core/validate.js.map +1 -1
- package/dist/playbook/catalog-loader.js +8 -3
- package/dist/playbook/catalog-loader.js.map +1 -1
- package/dist/playbook/import-mapper.js +4 -5
- package/dist/playbook/import-mapper.js.map +1 -1
- package/dist/poller/pr-readiness-core.js +226 -29
- package/dist/poller/pr-readiness-core.js.map +1 -1
- package/dist/revisium/inbox.service.js +0 -24
- package/dist/revisium/inbox.service.js.map +1 -1
- package/dist/revisium/playbooks.service.js +15 -2
- package/dist/revisium/playbooks.service.js.map +1 -1
- package/dist/revisium/revisium.module.js +0 -12
- package/dist/revisium/revisium.module.js.map +1 -1
- package/dist/revisium/roles.service.js +0 -6
- package/dist/revisium/roles.service.js.map +1 -1
- package/dist/revisium/run.service.js +2 -75
- package/dist/revisium/run.service.js.map +1 -1
- package/dist/revisium/tokens.js +0 -4
- package/dist/revisium/tokens.js.map +1 -1
- package/dist/run/append-event.js +6 -37
- package/dist/run/append-event.js.map +1 -1
- package/dist/run/block-run.js.map +1 -1
- package/dist/run/cancel-run.js +0 -33
- package/dist/run/cancel-run.js.map +1 -1
- package/dist/run/complete-run.js.map +1 -1
- package/dist/run/create-run.js +5 -8
- package/dist/run/create-run.js.map +1 -1
- package/dist/run/fail-run.js +0 -3
- package/dist/run/fail-run.js.map +1 -1
- package/dist/run/inspect-run.js +10 -29
- package/dist/run/inspect-run.js.map +1 -1
- package/dist/run/issue-ref.js +88 -0
- package/dist/run/issue-ref.js.map +1 -0
- package/dist/run/run-outputs.js +0 -19
- package/dist/run/run-outputs.js.map +1 -1
- package/dist/run/terminal-run-status.js +0 -12
- package/dist/run/terminal-run-status.js.map +1 -1
- package/dist/runners/claude-code.service.js +2 -13
- package/dist/runners/claude-code.service.js.map +1 -1
- package/dist/runners/codex.service.js +2 -11
- package/dist/runners/codex.service.js.map +1 -1
- package/dist/runners/gh-identity.js +0 -52
- package/dist/runners/gh-identity.js.map +1 -1
- package/dist/runners/integrator-branch-naming.js +16 -19
- package/dist/runners/integrator-branch-naming.js.map +1 -1
- package/dist/runners/integrator-git.js +0 -25
- package/dist/runners/integrator-git.js.map +1 -1
- package/dist/runners/integrator-remote.js +0 -2
- package/dist/runners/integrator-remote.js.map +1 -1
- package/dist/runners/integrator-types.js +0 -7
- package/dist/runners/integrator-types.js.map +1 -1
- package/dist/runners/integrator.js +231 -230
- package/dist/runners/integrator.js.map +1 -1
- package/dist/runners/runner.module.js +0 -6
- package/dist/runners/runner.module.js.map +1 -1
- package/dist/runners/tokens.js +0 -4
- package/dist/runners/tokens.js.map +1 -1
- package/dist/runners/worktree.service.js +3 -19
- package/dist/runners/worktree.service.js.map +1 -1
- package/dist/task-control-plane/pr-readiness.service.js +15 -13
- package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
- package/dist/task-control-plane/run-watch.service.js +170 -63
- package/dist/task-control-plane/run-watch.service.js.map +1 -1
- package/dist/task-control-plane/task-control-plane-api.service.js +285 -40
- package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
- package/dist/worker/artifact-store.js +21 -3
- package/dist/worker/artifact-store.js.map +1 -1
- package/dist/worker/build-context.js +10 -10
- package/dist/worker/build-context.js.map +1 -1
- package/dist/worker/claude-code-runner.js +76 -48
- package/dist/worker/claude-code-runner.js.map +1 -1
- package/dist/worker/codex-runner.js +128 -35
- package/dist/worker/codex-runner.js.map +1 -1
- package/dist/worker/git-worktree-manager.js +20 -67
- package/dist/worker/git-worktree-manager.js.map +1 -1
- package/dist/worker/process-executor.js +144 -27
- package/dist/worker/process-executor.js.map +1 -1
- package/dist/worker/result-envelope.js +33 -49
- package/dist/worker/result-envelope.js.map +1 -1
- package/dist/worker/runner-common.js +29 -0
- package/dist/worker/runner-common.js.map +1 -1
- package/dist/worker/runner-dispatch.js +0 -3
- package/dist/worker/runner-dispatch.js.map +1 -1
- package/dist/worker/runner.js +16 -1
- package/dist/worker/runner.js.map +1 -1
- package/dist/worker/stub-runner.js +0 -7
- package/dist/worker/stub-runner.js.map +1 -1
- 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;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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];
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
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 {
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
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: {
|
|
864
|
+
payload: {
|
|
865
|
+
prNumber: merged.prNumber,
|
|
866
|
+
prUrl: merged.prUrl,
|
|
867
|
+
...(merged.issueRef ? { issueRef: merged.issueRef } : {}),
|
|
868
|
+
},
|
|
580
869
|
});
|
|
581
|
-
return {
|
|
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: {
|
|
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: {
|
|
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 {
|
|
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:
|
|
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)
|