stageflow 0.16.0 → 0.19.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 +2 -4
- package/dist/agent/activity.d.ts +4 -0
- package/dist/agent/claudeAdapter.js +38 -0
- package/dist/agent/claudeTools.d.ts +1 -4
- package/dist/agent/claudeTools.js +1 -6
- package/dist/agent/fakeAgent.js +7 -6
- package/dist/agent/piAdapter.js +34 -15
- package/dist/agent/port.d.ts +1 -1
- package/dist/agent/stageTimeout.d.ts +9 -0
- package/dist/agent/stageTimeout.js +24 -0
- package/dist/cli/graphCommand.d.ts +15 -0
- package/dist/cli/graphCommand.js +79 -0
- package/dist/cli/graphRender.d.ts +2 -0
- package/dist/cli/graphRender.js +85 -0
- package/dist/cli/runsCommand.d.ts +1 -1
- package/dist/cli/runsCommand.js +37 -0
- package/dist/cli.js +12 -0
- package/dist/config/cloneChain.d.ts +12 -0
- package/dist/config/cloneChain.js +322 -0
- package/dist/config/createPipeline.js +101 -21
- package/dist/config/createStage.js +7 -0
- package/dist/config/loadOutcome.d.ts +1 -0
- package/dist/config/loadPipeline.d.ts +2 -0
- package/dist/config/loadPipeline.js +34 -22
- package/dist/config/loadStage.d.ts +2 -0
- package/dist/config/loadStage.js +135 -125
- package/dist/config/migrateYaml.js +14 -3
- package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
- package/dist/config/normalizePipelineStageEntry.js +133 -60
- package/dist/config/parsePreEmitChecks.js +2 -2
- package/dist/config/pipelineNeeds.d.ts +15 -2
- package/dist/config/pipelineNeeds.js +86 -4
- package/dist/config/pipelineRoute.d.ts +46 -0
- package/dist/config/pipelineRoute.js +215 -0
- package/dist/config/pipelineStageKeys.d.ts +2 -0
- package/dist/config/pipelineStageKeys.js +15 -4
- package/dist/config/printTargetYaml.js +18 -12
- package/dist/config/resolveForkEmitContext.d.ts +1 -1
- package/dist/config/resolveForkEmitContext.js +3 -26
- package/dist/config/resolvePipelineDag.d.ts +1 -3
- package/dist/config/resolvePipelineDag.js +143 -186
- package/dist/config/routeIf.d.ts +24 -0
- package/dist/config/routeIf.js +247 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +5 -3
- package/dist/config/yamlDialect.d.ts +5 -8
- package/dist/config/yamlDialect.js +37 -29
- package/dist/envelope/check.d.ts +0 -2
- package/dist/envelope/check.js +1 -57
- package/dist/envelope/feedbackLoop.js +0 -3
- package/dist/envelope/forkChoice.js +1 -1
- package/dist/envelope/payloadSchema.d.ts +14 -0
- package/dist/envelope/payloadSchema.js +87 -2
- package/dist/mcp/catalogTools.js +1 -2
- package/dist/mcp/controlTools.js +17 -0
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +0 -2
- package/dist/projection/projectRun.js +0 -3
- package/dist/prompt/priorEnvelope.js +1 -1
- package/dist/runstore/port.js +2 -0
- package/dist/runstore/stageInstanceId.d.ts +1 -0
- package/dist/runstore/stageInstanceId.js +9 -0
- package/dist/runtime/cloneSchedule.d.ts +16 -33
- package/dist/runtime/cloneSchedule.js +155 -236
- package/dist/runtime/envelopeRouting.js +43 -94
- package/dist/runtime/forkGeneration.d.ts +6 -2
- package/dist/runtime/forkGeneration.js +45 -15
- package/dist/runtime/joinReadiness.d.ts +16 -0
- package/dist/runtime/joinReadiness.js +156 -0
- package/dist/runtime/pipelineScheduler.d.ts +0 -3
- package/dist/runtime/pipelineScheduler.js +103 -65
- package/dist/runtime/reopenSkipped.d.ts +13 -0
- package/dist/runtime/reopenSkipped.js +56 -0
- package/dist/runtime/resumeTimedOut.d.ts +33 -0
- package/dist/runtime/resumeTimedOut.js +165 -0
- package/dist/runtime/routeIfEval.d.ts +3 -0
- package/dist/runtime/routeIfEval.js +94 -0
- package/dist/runtime/runManager.d.ts +1 -0
- package/dist/runtime/runManager.js +76 -0
- package/dist/runtime/runRetryCoordinator.js +0 -4
- package/dist/runtime/stageAttemptBootstrap.js +60 -11
- package/dist/runtime/stageRecovery.js +2 -2
- package/dist/runtime/stageRunner.js +6 -2
- package/dist/runtime/taskInput.js +1 -13
- package/dist/server/http.js +18 -0
- package/dist/tools/askOperator.d.ts +1 -0
- package/dist/tools/askOperator.js +14 -11
- package/dist/tools/emitStageEnvelope.d.ts +3 -3
- package/dist/tools/emitStageEnvelope.js +4 -66
- package/dist/tools/prepareToolArguments.d.ts +4 -0
- package/dist/tools/prepareToolArguments.js +79 -0
- package/dist/types/envelope.d.ts +0 -2
- package/dist/types/forkChoice.d.ts +0 -16
- package/dist/types/pipeline.d.ts +86 -27
- package/dist/types/stage.d.ts +11 -7
- package/dist/ui/assets/{index-D2Wt8hwH.css → index-CSsDued1.css} +1 -1
- package/dist/ui/assets/index-DMVdl1qB.js +119 -0
- package/dist/ui/index.html +2 -2
- package/package.json +2 -1
- package/skills/stageflow-author/SKILL.md +4 -4
- package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
- package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
- package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
- package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
- package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
- package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
- package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
- package/skills/stageflow-author/references/catalog-mapping.md +159 -50
- package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
- package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
- package/skills/stageflow-session-capture/SKILL.md +2 -2
- package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
- package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
- package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
- package/dist/envelope/cloneForks.d.ts +0 -2
- package/dist/envelope/cloneForks.js +0 -77
- package/dist/ui/assets/index-XWJovnp7.js +0 -118
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fakeHitlResumePath } from "../agent/fakeAgent.js";
|
|
3
|
+
import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
|
|
4
|
+
import { attemptSessionPath } from "../runstore/workspaceLayout.js";
|
|
5
|
+
import { WAIT_WITHOUT_WORKER_DISPATCH } from "./answerResume.js";
|
|
6
|
+
import { resumeRun } from "./pipelineScheduler.js";
|
|
7
|
+
import { loadRunContext } from "./resumeReconstruct.js";
|
|
8
|
+
import { isRunStageWaiting, runStage } from "./stageRunner.js";
|
|
9
|
+
import { attemptContext } from "./stageAttemptContext.js";
|
|
10
|
+
import { openStageAttempt, } from "./stageAttemptBootstrap.js";
|
|
11
|
+
import { buildStageRoots, withResolvedAuthPath } from "./stageRoots.js";
|
|
12
|
+
import { isStageTimeoutReason, lastFailedReason, } from "../agent/stageTimeout.js";
|
|
13
|
+
import { deriveExecutionPatchFromEvent } from "../runstore/stageExecution.js";
|
|
14
|
+
export function assertTimedOutStageEligible(status, events) {
|
|
15
|
+
if (status !== "failed") {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: `Stage is not timed out (status=${status})`,
|
|
19
|
+
status: 409,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!isStageTimeoutReason(lastFailedReason(events))) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: "Stage did not fail due to timeout; use retry to start a new attempt",
|
|
26
|
+
status: 409,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
export async function reconstructTimedOutAndContinue(ctx) {
|
|
32
|
+
const { runId, stageId, agent, store, cwd } = ctx;
|
|
33
|
+
const factoryCwd = ctx.factoryCwd ?? cwd;
|
|
34
|
+
try {
|
|
35
|
+
const latestExecution = await store.getLatestStageExecution(runId, stageId);
|
|
36
|
+
const attempt = latestExecution?.attempt ?? 1;
|
|
37
|
+
const attemptCtx = attemptContext(attempt);
|
|
38
|
+
const attemptOpt = attemptCtx.eventOptions();
|
|
39
|
+
const { meta, task, loaded } = await loadRunContext(store, runId, cwd);
|
|
40
|
+
const definitionId = definitionIdForInstance(meta.pipeline_dag, stageId);
|
|
41
|
+
const stageIndex = loaded.stages.findIndex((s) => s.id === definitionId);
|
|
42
|
+
if (stageIndex < 0) {
|
|
43
|
+
const reason = `Stage ${stageId} not in pipeline ${meta.pipeline_id}`;
|
|
44
|
+
await store.appendStageEvent(runId, stageId, { event: "failed", reason }, attemptOpt);
|
|
45
|
+
await store.updateRunStatus(runId, "failed");
|
|
46
|
+
return { ok: false, reason };
|
|
47
|
+
}
|
|
48
|
+
const stage = loaded.stages[stageIndex];
|
|
49
|
+
const dag = meta.pipeline_dag ?? loaded.dag;
|
|
50
|
+
const workspaceDir = store.getWorkspaceDir(runId);
|
|
51
|
+
const checkoutRoot = meta.checkout_root;
|
|
52
|
+
const roots = withResolvedAuthPath(buildStageRoots(workspaceDir, stageId, checkoutRoot, attemptCtx), factoryCwd);
|
|
53
|
+
const sessionFile = attemptSessionPath(workspaceDir, stageId, attempt);
|
|
54
|
+
const fakeResume = fakeHitlResumePath(roots, stageId);
|
|
55
|
+
if (!existsSync(sessionFile) && !existsSync(fakeResume)) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: "missing session to resume; use retry to start a new attempt",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const resumed = { event: "resumed" };
|
|
62
|
+
await store.appendStageEvent(runId, stageId, resumed, attemptOpt);
|
|
63
|
+
try {
|
|
64
|
+
const execution = await store.getStageExecution(runId, stageId, attempt);
|
|
65
|
+
const patch = deriveExecutionPatchFromEvent(resumed, execution);
|
|
66
|
+
if (Object.keys(patch).length > 0) {
|
|
67
|
+
await store.updateStageExecution(runId, stageId, attempt, patch);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// no execution row yet
|
|
72
|
+
}
|
|
73
|
+
await store.updateRunStatus(runId, "running");
|
|
74
|
+
const opened = await openStageAttempt({
|
|
75
|
+
agent,
|
|
76
|
+
store,
|
|
77
|
+
runId,
|
|
78
|
+
stage,
|
|
79
|
+
stageId,
|
|
80
|
+
task,
|
|
81
|
+
dag,
|
|
82
|
+
checkoutRoot,
|
|
83
|
+
workspaceDir,
|
|
84
|
+
factoryCwd,
|
|
85
|
+
attemptCtx,
|
|
86
|
+
operatorCatalog: ctx.operatorCatalog,
|
|
87
|
+
sessionMode: "timeout_resume",
|
|
88
|
+
resumeToken: sessionFile,
|
|
89
|
+
onActivity: (event) => {
|
|
90
|
+
void store.appendStageEvent(runId, stageId, event, attemptOpt);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
if (!opened.ok) {
|
|
94
|
+
await store.appendStageEvent(runId, stageId, { event: "failed", reason: opened.reason }, attemptOpt);
|
|
95
|
+
await store.updateRunStatus(runId, "failed");
|
|
96
|
+
return { ok: false, reason: opened.reason };
|
|
97
|
+
}
|
|
98
|
+
const result = await runStage({
|
|
99
|
+
agent,
|
|
100
|
+
store,
|
|
101
|
+
runId,
|
|
102
|
+
stage,
|
|
103
|
+
stageId,
|
|
104
|
+
task,
|
|
105
|
+
dag,
|
|
106
|
+
checkoutRoot,
|
|
107
|
+
workspaceDir,
|
|
108
|
+
hitl: ctx.hitl,
|
|
109
|
+
skipStarted: true,
|
|
110
|
+
existingHandle: opened.handle,
|
|
111
|
+
attemptCtx,
|
|
112
|
+
factoryCwd,
|
|
113
|
+
operatorCatalog: ctx.operatorCatalog,
|
|
114
|
+
sessionMode: "timeout_resume",
|
|
115
|
+
resumeToken: sessionFile,
|
|
116
|
+
});
|
|
117
|
+
if (isRunStageWaiting(result)) {
|
|
118
|
+
await store.updateRunStatus(runId, "failed");
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: WAIT_WITHOUT_WORKER_DISPATCH,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
await store.updateRunStatus(runId, "failed");
|
|
126
|
+
return { ok: false, reason: result.reason };
|
|
127
|
+
}
|
|
128
|
+
const rest = await resumeRun({
|
|
129
|
+
prepared: {
|
|
130
|
+
task,
|
|
131
|
+
loaded,
|
|
132
|
+
run: { runId, workspaceDir },
|
|
133
|
+
agent,
|
|
134
|
+
store,
|
|
135
|
+
cwd,
|
|
136
|
+
projectRoot: factoryCwd,
|
|
137
|
+
checkoutRoot,
|
|
138
|
+
hitl: ctx.hitl,
|
|
139
|
+
operatorCatalog: ctx.operatorCatalog,
|
|
140
|
+
},
|
|
141
|
+
maxActiveStagesPerRun: ctx.maxActiveStagesPerRun,
|
|
142
|
+
resumeFromStageId: stageId,
|
|
143
|
+
initialPrior: result.envelope ?? null,
|
|
144
|
+
executionMode: ctx.executionMode,
|
|
145
|
+
stageProcessLauncher: ctx.stageProcessLauncher,
|
|
146
|
+
});
|
|
147
|
+
if (rest.outcome === "failed") {
|
|
148
|
+
return { ok: false, reason: rest.reason };
|
|
149
|
+
}
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const reason = err instanceof Error ? err.message : `timeout resume failed: ${String(err)}`;
|
|
154
|
+
try {
|
|
155
|
+
const latestExecution = await store.getLatestStageExecution(runId, stageId);
|
|
156
|
+
const attemptCtx = attemptContext(latestExecution?.attempt ?? 1);
|
|
157
|
+
await store.appendStageEvent(runId, stageId, { event: "failed", reason }, attemptCtx.eventOptions());
|
|
158
|
+
await store.updateRunStatus(runId, "failed");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// ignore secondary failures
|
|
162
|
+
}
|
|
163
|
+
return { ok: false, reason };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const NUMERIC_OPS = new Set(["gt", "gte", "lt", "lte"]);
|
|
2
|
+
function isPlainObject(value) {
|
|
3
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function hasOwn(object, key) {
|
|
6
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
7
|
+
}
|
|
8
|
+
function isRouteIfLeaf(predicate) {
|
|
9
|
+
return "op" in predicate && predicate.op !== undefined && typeof predicate.field === "string";
|
|
10
|
+
}
|
|
11
|
+
function readPayloadPath(payload, field) {
|
|
12
|
+
if (payload === undefined)
|
|
13
|
+
return { ok: false };
|
|
14
|
+
const segments = field.split(".");
|
|
15
|
+
let current = payload;
|
|
16
|
+
for (let i = 0; i < segments.length; i++) {
|
|
17
|
+
const segment = segments[i];
|
|
18
|
+
if (!isPlainObject(current) || !hasOwn(current, segment)) {
|
|
19
|
+
return { ok: false };
|
|
20
|
+
}
|
|
21
|
+
current = current[segment];
|
|
22
|
+
if (i < segments.length - 1 && !isPlainObject(current)) {
|
|
23
|
+
return { ok: false };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, value: current };
|
|
27
|
+
}
|
|
28
|
+
function compareLeaf(op, actual, expected) {
|
|
29
|
+
if (op === "eq")
|
|
30
|
+
return actual === expected ? "fire" : "miss";
|
|
31
|
+
if (op === "ne")
|
|
32
|
+
return actual !== expected ? "fire" : "miss";
|
|
33
|
+
if (NUMERIC_OPS.has(op)) {
|
|
34
|
+
if (typeof actual !== "number" || !Number.isFinite(actual))
|
|
35
|
+
return "miss";
|
|
36
|
+
if (typeof expected !== "number" || !Number.isFinite(expected))
|
|
37
|
+
return "miss";
|
|
38
|
+
if (op === "gt")
|
|
39
|
+
return actual > expected ? "fire" : "miss";
|
|
40
|
+
if (op === "gte")
|
|
41
|
+
return actual >= expected ? "fire" : "miss";
|
|
42
|
+
if (op === "lt")
|
|
43
|
+
return actual < expected ? "fire" : "miss";
|
|
44
|
+
if (op === "lte")
|
|
45
|
+
return actual <= expected ? "fire" : "miss";
|
|
46
|
+
}
|
|
47
|
+
if (op === "in") {
|
|
48
|
+
if (!Array.isArray(expected))
|
|
49
|
+
return "miss";
|
|
50
|
+
return expected.includes(actual) ? "fire" : "miss";
|
|
51
|
+
}
|
|
52
|
+
if (op === "not_in") {
|
|
53
|
+
if (!Array.isArray(expected))
|
|
54
|
+
return "miss";
|
|
55
|
+
return expected.includes(actual) ? "miss" : "fire";
|
|
56
|
+
}
|
|
57
|
+
return "miss";
|
|
58
|
+
}
|
|
59
|
+
export function evaluateRouteIf(predicate, payload) {
|
|
60
|
+
if ("all" in predicate && predicate.all !== undefined) {
|
|
61
|
+
let anyMiss = false;
|
|
62
|
+
for (const child of predicate.all) {
|
|
63
|
+
const result = evaluateRouteIf(child, payload);
|
|
64
|
+
if (result === "missing_field")
|
|
65
|
+
return "missing_field";
|
|
66
|
+
if (result === "miss")
|
|
67
|
+
anyMiss = true;
|
|
68
|
+
}
|
|
69
|
+
return anyMiss ? "miss" : "fire";
|
|
70
|
+
}
|
|
71
|
+
if ("any" in predicate && predicate.any !== undefined) {
|
|
72
|
+
let anyFire = false;
|
|
73
|
+
for (const child of predicate.any) {
|
|
74
|
+
const result = evaluateRouteIf(child, payload);
|
|
75
|
+
if (result === "missing_field")
|
|
76
|
+
return "missing_field";
|
|
77
|
+
if (result === "fire")
|
|
78
|
+
anyFire = true;
|
|
79
|
+
}
|
|
80
|
+
return anyFire ? "fire" : "miss";
|
|
81
|
+
}
|
|
82
|
+
if ("not" in predicate && predicate.not !== undefined) {
|
|
83
|
+
const result = evaluateRouteIf(predicate.not, payload);
|
|
84
|
+
if (result === "missing_field")
|
|
85
|
+
return "missing_field";
|
|
86
|
+
return result === "fire" ? "miss" : "fire";
|
|
87
|
+
}
|
|
88
|
+
if (!isRouteIfLeaf(predicate))
|
|
89
|
+
return "missing_field";
|
|
90
|
+
const read = readPayloadPath(payload, predicate.field);
|
|
91
|
+
if (!read.ok)
|
|
92
|
+
return "missing_field";
|
|
93
|
+
return compareLeaf(predicate.op, read.value, predicate.value);
|
|
94
|
+
}
|
|
@@ -135,6 +135,7 @@ export declare class RunManager {
|
|
|
135
135
|
}): Promise<StartRunResult>;
|
|
136
136
|
rerun(runId: string): Promise<StartRunResult>;
|
|
137
137
|
retryStage(runId: string, stageId: string): Promise<RetryStageResult>;
|
|
138
|
+
resumeTimedOutStage(runId: string, stageId: string): Promise<RetryStageResult>;
|
|
138
139
|
recoverManualStage(runId: string, stageId: string, guidance?: string): Promise<RetryStageResult>;
|
|
139
140
|
recoverManualStageUntilStop(runId: string, stageId: string, guidance?: string): Promise<{
|
|
140
141
|
ok: true;
|
|
@@ -3,6 +3,7 @@ import { readFile, realpath } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { normalizeCatalogPath } from "../runstore/normalizeCatalogPath.js";
|
|
5
5
|
import { loadRunContext } from "./resumeReconstruct.js";
|
|
6
|
+
import { assertTimedOutStageEligible, reconstructTimedOutAndContinue, } from "./resumeTimedOut.js";
|
|
6
7
|
import { loadTaskFromYaml } from "../config/loadTask.js";
|
|
7
8
|
import { deriveStatusFromStages, findUnhandledFailedStage, } from "../runstore/port.js";
|
|
8
9
|
import { PipelineValidationError, startPipeline, } from "./pipelineRunner.js";
|
|
@@ -417,6 +418,81 @@ export class RunManager {
|
|
|
417
418
|
async retryStage(runId, stageId) {
|
|
418
419
|
return this.retryStageInternal(runId, stageId, true);
|
|
419
420
|
}
|
|
421
|
+
async resumeTimedOutStage(runId, stageId) {
|
|
422
|
+
const resumeKey = waitKey(runId, stageId);
|
|
423
|
+
if (this.resumeInFlight.has(resumeKey) || this.retryInFlight.has(resumeKey)) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
reason: `Resume already in progress for run ${runId} stage ${stageId}`,
|
|
427
|
+
status: 409,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
this.resumeInFlight.add(resumeKey);
|
|
431
|
+
let detail;
|
|
432
|
+
try {
|
|
433
|
+
detail = await this.options.store.readRun(runId);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
this.resumeInFlight.delete(resumeKey);
|
|
437
|
+
return { ok: false, reason: `Run not found: ${runId}`, status: 404 };
|
|
438
|
+
}
|
|
439
|
+
const stageSnap = detail.stages.find((s) => s.stage_id === stageId);
|
|
440
|
+
if (!stageSnap) {
|
|
441
|
+
this.resumeInFlight.delete(resumeKey);
|
|
442
|
+
return { ok: false, reason: `Stage not found: ${stageId}`, status: 404 };
|
|
443
|
+
}
|
|
444
|
+
const eligibility = assertTimedOutStageEligible(stageSnap.status, stageSnap.events);
|
|
445
|
+
if (!eligibility.ok) {
|
|
446
|
+
this.resumeInFlight.delete(resumeKey);
|
|
447
|
+
return eligibility;
|
|
448
|
+
}
|
|
449
|
+
const latest = await this.options.store.getLatestStageExecution(runId, stageId);
|
|
450
|
+
const attemptIndex = latest?.attempt ?? 1;
|
|
451
|
+
const wasActive = this.active.has(runId);
|
|
452
|
+
const tracked = await this.ensureResumeTracked(runId);
|
|
453
|
+
if (!tracked.ok) {
|
|
454
|
+
this.resumeInFlight.delete(resumeKey);
|
|
455
|
+
return { ok: false, reason: tracked.reason, status: 409 };
|
|
456
|
+
}
|
|
457
|
+
const insertedForResume = !wasActive;
|
|
458
|
+
try {
|
|
459
|
+
const done = reconstructTimedOutAndContinue({
|
|
460
|
+
runId,
|
|
461
|
+
stageId,
|
|
462
|
+
agent: this.options.agent,
|
|
463
|
+
store: this.options.store,
|
|
464
|
+
hitl: this.hitl,
|
|
465
|
+
executionMode: this.executionMode,
|
|
466
|
+
cwd: this.cwd,
|
|
467
|
+
maxActiveStagesPerRun: this.maxActiveStagesPerRun,
|
|
468
|
+
factoryCwd: this.projectRoot,
|
|
469
|
+
...(this.stageProcessLauncher !== undefined
|
|
470
|
+
? { stageProcessLauncher: this.stageProcessLauncher }
|
|
471
|
+
: {}),
|
|
472
|
+
...(this.options.operatorCatalog !== undefined
|
|
473
|
+
? { operatorCatalog: this.options.operatorCatalog }
|
|
474
|
+
: {}),
|
|
475
|
+
});
|
|
476
|
+
this.registerResumeUntrack(runId, done);
|
|
477
|
+
const outcome = await done;
|
|
478
|
+
if (!outcome.ok) {
|
|
479
|
+
if (insertedForResume) {
|
|
480
|
+
this.removeActiveEntry(runId, false);
|
|
481
|
+
}
|
|
482
|
+
const missingSession = outcome.reason !== undefined &&
|
|
483
|
+
outcome.reason.startsWith("missing session to resume");
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
reason: outcome.reason ?? "timeout resume failed",
|
|
487
|
+
status: missingSession ? 409 : 500,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, runId, stageId, attemptIndex };
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
this.resumeInFlight.delete(resumeKey);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
420
496
|
async recoverManualStage(runId, stageId, guidance) {
|
|
421
497
|
return this.recoverManualStageInternal(runId, stageId, guidance, true);
|
|
422
498
|
}
|
|
@@ -25,10 +25,6 @@ export function succeededStageRetryBlocker(stageSnap) {
|
|
|
25
25
|
stageSnap.definition_id !== stageSnap.stage_id) {
|
|
26
26
|
return `Cannot re-run a succeeded clone instance stage (stage=${stageSnap.stage_id}, definition=${stageSnap.definition_id}): clone instances are created by upstream fan-out and cannot be rebuilt in place. Use rerun to start a new run.`;
|
|
27
27
|
}
|
|
28
|
-
const forks = stageSnap.envelope?.clone_forks;
|
|
29
|
-
if (Array.isArray(forks) && forks.length > 0) {
|
|
30
|
-
return `Cannot re-run a succeeded clonable-fanout stage (stage=${stageSnap.stage_id}): its envelope declared clone_forks and existing clone instances cannot be pruned. Use rerun to start a new run.`;
|
|
31
|
-
}
|
|
32
28
|
if (stageSnap.envelope?.fork_choice !== undefined) {
|
|
33
29
|
return `Cannot re-run a succeeded fork stage (stage=${stageSnap.stage_id}): its envelope recorded a fork_choice and skipped branches cannot be restored. Use rerun to start a new run.`;
|
|
34
30
|
}
|
|
@@ -4,7 +4,9 @@ import { MCP_CATALOG_FILENAME, STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV, StageMcpError,
|
|
|
4
4
|
import { attemptArtifactsDir } from "../runstore/workspaceLayout.js";
|
|
5
5
|
import { resolveCloneEmitContext, resolveForkEmitContext } from "../config/resolveForkEmitContext.js";
|
|
6
6
|
import { createAttemptQaTrailReader } from "../hitl/qaTrail.js";
|
|
7
|
+
import { assertPriorInputPayload, payloadInstanceMismatch, } from "../envelope/payloadSchema.js";
|
|
7
8
|
import { buildCompletedEnvelopesFromRun, resolvePriorEnvelope, } from "./envelopeRouting.js";
|
|
9
|
+
import { assignment } from "./cloneSchedule.js";
|
|
8
10
|
import { resumeSessionFilePath } from "./stageAttemptContext.js";
|
|
9
11
|
import { buildStageRoots, rootsForStageWorker, withResolvedAuthPath, } from "./stageRoots.js";
|
|
10
12
|
async function resolveStageSkillForRun(stage, catalog) {
|
|
@@ -76,6 +78,51 @@ async function openStageWithOperatorCatalog(agent, input, catalog, factoryCwd, a
|
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
}
|
|
81
|
+
function assertPriorsMatchCloneInput(cloneInputSchema, childId, priorResult, task, skipCloneAssignment) {
|
|
82
|
+
if (cloneInputSchema === undefined)
|
|
83
|
+
return { ok: true };
|
|
84
|
+
if (skipCloneAssignment)
|
|
85
|
+
return { ok: true };
|
|
86
|
+
try {
|
|
87
|
+
if (priorResult.priorEnvelopesByStage !== undefined) {
|
|
88
|
+
for (const value of Object.values(priorResult.priorEnvelopesByStage)) {
|
|
89
|
+
const envelopes = Array.isArray(value) ? value : [value];
|
|
90
|
+
for (const envelope of envelopes) {
|
|
91
|
+
if (envelope.status !== "success")
|
|
92
|
+
continue;
|
|
93
|
+
assertPriorInputPayload(envelope, cloneInputSchema, childId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
if (priorResult.joinPriors !== undefined) {
|
|
99
|
+
for (const envelope of priorResult.joinPriors) {
|
|
100
|
+
if (envelope.status !== "success")
|
|
101
|
+
continue;
|
|
102
|
+
assertPriorInputPayload(envelope, cloneInputSchema, childId);
|
|
103
|
+
}
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
if (priorResult.prior !== null) {
|
|
107
|
+
assertPriorInputPayload(priorResult.prior, cloneInputSchema, childId);
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
const details = payloadInstanceMismatch(task.input ?? {}, cloneInputSchema);
|
|
111
|
+
if (details !== undefined) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: `task input does not match io.input.schema for ${childId}: ${details}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
79
126
|
function resolveAttemptRoots(input, stageId) {
|
|
80
127
|
const baseRoots = input.roots ??
|
|
81
128
|
(input.workerRoots
|
|
@@ -136,15 +183,20 @@ export async function openStageAttempt(input) {
|
|
|
136
183
|
const definitionId = input.stage.id;
|
|
137
184
|
const completedEnvelopes = input.completedEnvelopes ??
|
|
138
185
|
(await buildCompletedEnvelopesFromRun(input.store, input.runId, undefined, input.dag));
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
186
|
+
const assigned = assignment(input.dag, stageId, definitionId, completedEnvelopes);
|
|
187
|
+
const priorResult = assigned ??
|
|
188
|
+
(await resolvePriorEnvelope({
|
|
189
|
+
dag: input.dag,
|
|
190
|
+
stageId,
|
|
191
|
+
completedEnvelopes,
|
|
192
|
+
store: input.store,
|
|
193
|
+
runId: input.runId,
|
|
194
|
+
}));
|
|
146
195
|
if (!priorResult.ok)
|
|
147
196
|
return priorResult;
|
|
197
|
+
const priorInput = assertPriorsMatchCloneInput(input.stage.clone_input_schema, stageId, priorResult, input.task, false);
|
|
198
|
+
if (!priorInput.ok)
|
|
199
|
+
return priorInput;
|
|
148
200
|
const roots = resolveAttemptRoots(input, stageId);
|
|
149
201
|
const attempt = input.attemptCtx?.attempt ?? 1;
|
|
150
202
|
const resumeToken = input.resumeToken ??
|
|
@@ -152,9 +204,6 @@ export async function openStageAttempt(input) {
|
|
|
152
204
|
const forkEmitContext = resolveForkEmitContext(input.dag, definitionId);
|
|
153
205
|
const snapshot = input.dag;
|
|
154
206
|
const cloneEmitContext = resolveCloneEmitContext(input.dag, definitionId, {
|
|
155
|
-
...(input.stage.clone_actions !== undefined
|
|
156
|
-
? { allowedActions: input.stage.clone_actions }
|
|
157
|
-
: {}),
|
|
158
207
|
...(snapshot.clone_input_schema !== undefined
|
|
159
208
|
? { successorCloneInputSchemas: snapshot.clone_input_schema }
|
|
160
209
|
: {}),
|
|
@@ -163,7 +212,7 @@ export async function openStageAttempt(input) {
|
|
|
163
212
|
const runtimeNode = input.dag.nodes.find((node) => node.id === stageId);
|
|
164
213
|
const isDynamicCloneInstance = runtimeNode?.definition_id !== undefined &&
|
|
165
214
|
runtimeNode.definition_id !== runtimeNode.id;
|
|
166
|
-
const feedbackLoopEmitContext = isDynamicCloneInstance
|
|
215
|
+
const feedbackLoopEmitContext = isDynamicCloneInstance
|
|
167
216
|
? undefined
|
|
168
217
|
: runtimeNode?.feedback_loop;
|
|
169
218
|
const repairContext = await repairContextForAttempt(input, stageId, attempt);
|
|
@@ -35,10 +35,10 @@ export async function failStageAsInterrupted(options) {
|
|
|
35
35
|
}
|
|
36
36
|
export async function syncRunStatusFromStages(store, runId) {
|
|
37
37
|
const meta = await store.readRunMeta(runId);
|
|
38
|
-
if (meta.status === "succeeded")
|
|
39
|
-
return;
|
|
40
38
|
const run = await store.readRun(runId);
|
|
41
39
|
const derived = deriveStatusFromStages(run.stages, meta.pipeline_dag);
|
|
40
|
+
if (meta.status === "succeeded" && derived !== "running")
|
|
41
|
+
return;
|
|
42
42
|
if ((meta.status ?? "created") !== derived) {
|
|
43
43
|
await store.updateRunStatus(runId, derived);
|
|
44
44
|
}
|
|
@@ -3,6 +3,7 @@ import { buildStageRoots, withResolvedAuthPath, } from "./stageRoots.js";
|
|
|
3
3
|
import { createDefaultHitlQaHooks, recordWaitEnter, } from "./stageHitl.js";
|
|
4
4
|
import { openStageAttempt, } from "./stageAttemptBootstrap.js";
|
|
5
5
|
import { createVerifiedStageExecution } from "./verifiedStageExecution.js";
|
|
6
|
+
import { isStageTimeoutReason } from "../agent/stageTimeout.js";
|
|
6
7
|
export function isRunStageWaiting(outcome) {
|
|
7
8
|
return "waiting" in outcome && outcome.waiting === true;
|
|
8
9
|
}
|
|
@@ -220,7 +221,9 @@ export async function runStage(options) {
|
|
|
220
221
|
attemptCtx,
|
|
221
222
|
});
|
|
222
223
|
console.error(`Stage ${stageId} failed: ${reason}`);
|
|
223
|
-
await handle
|
|
224
|
+
await handle
|
|
225
|
+
.close(isStageTimeoutReason(reason) ? { park: true } : undefined)
|
|
226
|
+
.catch(() => undefined);
|
|
224
227
|
return { ok: false, reason };
|
|
225
228
|
}
|
|
226
229
|
if (isRunStageWaiting(outcome)) {
|
|
@@ -251,6 +254,7 @@ export async function runStage(options) {
|
|
|
251
254
|
});
|
|
252
255
|
}
|
|
253
256
|
finally {
|
|
254
|
-
|
|
257
|
+
const timedOut = isStageTimeoutReason(outcome.ok ? undefined : outcome.reason);
|
|
258
|
+
await handle.close(timedOut ? { park: true } : undefined).catch(() => undefined);
|
|
255
259
|
}
|
|
256
260
|
}
|
|
@@ -34,21 +34,9 @@ export function checkTaskEntryInput(task, loaded, options) {
|
|
|
34
34
|
const stage = stageById.get(rootId);
|
|
35
35
|
if (stage?.clone_input_schema === undefined)
|
|
36
36
|
continue;
|
|
37
|
-
if (task.input === undefined) {
|
|
38
|
-
findings.push({
|
|
39
|
-
severity: "warning",
|
|
40
|
-
code: "task.entry_input_unmet",
|
|
41
|
-
path: findingPath,
|
|
42
|
-
message: `Task has no input; entry stage "${rootId}" requires io.input`,
|
|
43
|
-
category: "task",
|
|
44
|
-
pipelineId: loaded.pipeline.id,
|
|
45
|
-
stageId: rootId,
|
|
46
|
-
});
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
37
|
let details;
|
|
50
38
|
try {
|
|
51
|
-
details = payloadInstanceMismatch(task.input, stage.clone_input_schema);
|
|
39
|
+
details = payloadInstanceMismatch(task.input ?? {}, stage.clone_input_schema);
|
|
52
40
|
}
|
|
53
41
|
catch (err) {
|
|
54
42
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/server/http.js
CHANGED
|
@@ -91,6 +91,7 @@ function isMutatingApi(method, pathname) {
|
|
|
91
91
|
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/answer$/.test(pathname) ||
|
|
92
92
|
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/feedback-decision$/.test(pathname) ||
|
|
93
93
|
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/retry$/.test(pathname) ||
|
|
94
|
+
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/resume$/.test(pathname) ||
|
|
94
95
|
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/recovery$/.test(pathname) ||
|
|
95
96
|
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/recovery\/stop$/.test(pathname) ||
|
|
96
97
|
/^\/api\/runs\/[^/]+\/stages\/[^/]+\/abandon$/.test(pathname) ||
|
|
@@ -390,6 +391,23 @@ export async function startUiServer(options) {
|
|
|
390
391
|
});
|
|
391
392
|
return true;
|
|
392
393
|
}
|
|
394
|
+
const resumeMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/resume$/);
|
|
395
|
+
if (method === "POST" && resumeMatch) {
|
|
396
|
+
await readJsonBody(req);
|
|
397
|
+
const runId = decodeURIComponent(resumeMatch[1] ?? "");
|
|
398
|
+
const stageId = decodeURIComponent(resumeMatch[2] ?? "");
|
|
399
|
+
const result = await manager.resumeTimedOutStage(runId, stageId);
|
|
400
|
+
if (!result.ok) {
|
|
401
|
+
json(res, result.status ?? 500, mapRetryStageFailure(result));
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
json(res, 202, {
|
|
405
|
+
runId: result.runId,
|
|
406
|
+
stageId: result.stageId,
|
|
407
|
+
attemptIndex: result.attemptIndex,
|
|
408
|
+
});
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
393
411
|
const recoverMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/recovery$/);
|
|
394
412
|
if (method === "POST" && recoverMatch) {
|
|
395
413
|
let body;
|
|
@@ -330,6 +330,7 @@ export declare function createAskOperatorTool(options: AskOperatorToolOptions):
|
|
|
330
330
|
artifacts: Type.TArray<Type.TString>;
|
|
331
331
|
id: Type.TOptional<Type.TString>;
|
|
332
332
|
}>)[]]>;
|
|
333
|
+
prepareArguments: (args: unknown) => never;
|
|
333
334
|
execute: (_toolCallId: string, params: unknown) => Promise<{
|
|
334
335
|
content: {
|
|
335
336
|
type: "text";
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
+
import { prepareAskOperatorArguments } from "./prepareToolArguments.js";
|
|
12
13
|
export class AskOperatorError extends Error {
|
|
13
14
|
constructor(message) {
|
|
14
15
|
super(message);
|
|
@@ -168,10 +169,11 @@ function parseSubQuestion(value, path) {
|
|
|
168
169
|
};
|
|
169
170
|
}
|
|
170
171
|
export function parseAskOperatorParams(value) {
|
|
171
|
-
|
|
172
|
+
const prepared = prepareAskOperatorArguments(value);
|
|
173
|
+
if (!isRecord(prepared)) {
|
|
172
174
|
throw new AskOperatorError("ask_operator params must be an object");
|
|
173
175
|
}
|
|
174
|
-
const kind =
|
|
176
|
+
const kind = prepared.kind;
|
|
175
177
|
if (typeof kind !== "string") {
|
|
176
178
|
throw new AskOperatorError("kind is required");
|
|
177
179
|
}
|
|
@@ -181,36 +183,36 @@ export function parseAskOperatorParams(value) {
|
|
|
181
183
|
kind !== "artifact_backed") {
|
|
182
184
|
throw new AskOperatorError(`unsupported prompt kind "${kind}" (allowed: free_text, confirm, multi_question, artifact_backed)`);
|
|
183
185
|
}
|
|
184
|
-
const id = optionalId(
|
|
186
|
+
const id = optionalId(prepared.id, "id");
|
|
185
187
|
if (kind === "free_text" || kind === "confirm") {
|
|
186
188
|
return {
|
|
187
189
|
kind,
|
|
188
|
-
message: requireNonEmptyString(
|
|
190
|
+
message: requireNonEmptyString(prepared.message, "message"),
|
|
189
191
|
...(id !== undefined ? { id } : {}),
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
if (kind === "artifact_backed") {
|
|
193
|
-
if (!Array.isArray(
|
|
195
|
+
if (!Array.isArray(prepared.artifacts)) {
|
|
194
196
|
throw new AskOperatorError("artifacts must be an array");
|
|
195
197
|
}
|
|
196
|
-
if (
|
|
198
|
+
if (prepared.artifacts.length < 1) {
|
|
197
199
|
throw new AskOperatorError("artifacts must contain at least one path");
|
|
198
200
|
}
|
|
199
|
-
const artifacts =
|
|
201
|
+
const artifacts = prepared.artifacts.map((item, index) => requireNonEmptyString(item, `artifacts[${index}]`));
|
|
200
202
|
return {
|
|
201
203
|
kind,
|
|
202
|
-
message: requireNonEmptyString(
|
|
204
|
+
message: requireNonEmptyString(prepared.message, "message"),
|
|
203
205
|
artifacts,
|
|
204
206
|
...(id !== undefined ? { id } : {}),
|
|
205
207
|
};
|
|
206
208
|
}
|
|
207
|
-
if (!Array.isArray(
|
|
209
|
+
if (!Array.isArray(prepared.questions)) {
|
|
208
210
|
throw new AskOperatorError("questions must be an array");
|
|
209
211
|
}
|
|
210
|
-
if (
|
|
212
|
+
if (prepared.questions.length < 1) {
|
|
211
213
|
throw new AskOperatorError("questions must contain at least one item");
|
|
212
214
|
}
|
|
213
|
-
const questions =
|
|
215
|
+
const questions = prepared.questions.map((item, index) => parseSubQuestion(item, `questions[${index}]`));
|
|
214
216
|
return {
|
|
215
217
|
kind,
|
|
216
218
|
questions,
|
|
@@ -401,6 +403,7 @@ export function createAskOperatorTool(options) {
|
|
|
401
403
|
label: "Ask operator",
|
|
402
404
|
description: `Ask the operator a question and wait for their answer. Supports ${kindList} prompts. Does not complete the stage — call emit_stage_envelope when finished.`,
|
|
403
405
|
parameters: askOperatorParamsSchemaFor(allowedKinds),
|
|
406
|
+
prepareArguments: prepareAskOperatorArguments,
|
|
404
407
|
execute: async (_toolCallId, params) => {
|
|
405
408
|
let prompt;
|
|
406
409
|
try {
|