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.
Files changed (124) hide show
  1. package/README.md +2 -4
  2. package/dist/agent/activity.d.ts +4 -0
  3. package/dist/agent/claudeAdapter.js +38 -0
  4. package/dist/agent/claudeTools.d.ts +1 -4
  5. package/dist/agent/claudeTools.js +1 -6
  6. package/dist/agent/fakeAgent.js +7 -6
  7. package/dist/agent/piAdapter.js +34 -15
  8. package/dist/agent/port.d.ts +1 -1
  9. package/dist/agent/stageTimeout.d.ts +9 -0
  10. package/dist/agent/stageTimeout.js +24 -0
  11. package/dist/cli/graphCommand.d.ts +15 -0
  12. package/dist/cli/graphCommand.js +79 -0
  13. package/dist/cli/graphRender.d.ts +2 -0
  14. package/dist/cli/graphRender.js +85 -0
  15. package/dist/cli/runsCommand.d.ts +1 -1
  16. package/dist/cli/runsCommand.js +37 -0
  17. package/dist/cli.js +12 -0
  18. package/dist/config/cloneChain.d.ts +12 -0
  19. package/dist/config/cloneChain.js +322 -0
  20. package/dist/config/createPipeline.js +101 -21
  21. package/dist/config/createStage.js +7 -0
  22. package/dist/config/loadOutcome.d.ts +1 -0
  23. package/dist/config/loadPipeline.d.ts +2 -0
  24. package/dist/config/loadPipeline.js +34 -22
  25. package/dist/config/loadStage.d.ts +2 -0
  26. package/dist/config/loadStage.js +135 -125
  27. package/dist/config/migrateYaml.js +14 -3
  28. package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
  29. package/dist/config/normalizePipelineStageEntry.js +133 -60
  30. package/dist/config/parsePreEmitChecks.js +2 -2
  31. package/dist/config/pipelineNeeds.d.ts +15 -2
  32. package/dist/config/pipelineNeeds.js +86 -4
  33. package/dist/config/pipelineRoute.d.ts +46 -0
  34. package/dist/config/pipelineRoute.js +215 -0
  35. package/dist/config/pipelineStageKeys.d.ts +2 -0
  36. package/dist/config/pipelineStageKeys.js +15 -4
  37. package/dist/config/printTargetYaml.js +18 -12
  38. package/dist/config/resolveForkEmitContext.d.ts +1 -1
  39. package/dist/config/resolveForkEmitContext.js +3 -26
  40. package/dist/config/resolvePipelineDag.d.ts +1 -3
  41. package/dist/config/resolvePipelineDag.js +143 -186
  42. package/dist/config/routeIf.d.ts +24 -0
  43. package/dist/config/routeIf.js +247 -0
  44. package/dist/config/validateCatalog.d.ts +1 -1
  45. package/dist/config/validateCatalog.js +5 -3
  46. package/dist/config/yamlDialect.d.ts +5 -8
  47. package/dist/config/yamlDialect.js +37 -29
  48. package/dist/envelope/check.d.ts +0 -2
  49. package/dist/envelope/check.js +1 -57
  50. package/dist/envelope/feedbackLoop.js +0 -3
  51. package/dist/envelope/forkChoice.js +1 -1
  52. package/dist/envelope/payloadSchema.d.ts +14 -0
  53. package/dist/envelope/payloadSchema.js +87 -2
  54. package/dist/mcp/catalogTools.js +1 -2
  55. package/dist/mcp/controlTools.js +17 -0
  56. package/dist/package-meta.d.ts +1 -1
  57. package/dist/package-meta.js +1 -1
  58. package/dist/projection/projectRun.d.ts +0 -2
  59. package/dist/projection/projectRun.js +0 -3
  60. package/dist/prompt/priorEnvelope.js +1 -1
  61. package/dist/runstore/port.js +2 -0
  62. package/dist/runstore/stageInstanceId.d.ts +1 -0
  63. package/dist/runstore/stageInstanceId.js +9 -0
  64. package/dist/runtime/cloneSchedule.d.ts +16 -33
  65. package/dist/runtime/cloneSchedule.js +155 -236
  66. package/dist/runtime/envelopeRouting.js +43 -94
  67. package/dist/runtime/forkGeneration.d.ts +6 -2
  68. package/dist/runtime/forkGeneration.js +45 -15
  69. package/dist/runtime/joinReadiness.d.ts +16 -0
  70. package/dist/runtime/joinReadiness.js +156 -0
  71. package/dist/runtime/pipelineScheduler.d.ts +0 -3
  72. package/dist/runtime/pipelineScheduler.js +103 -65
  73. package/dist/runtime/reopenSkipped.d.ts +13 -0
  74. package/dist/runtime/reopenSkipped.js +56 -0
  75. package/dist/runtime/resumeTimedOut.d.ts +33 -0
  76. package/dist/runtime/resumeTimedOut.js +165 -0
  77. package/dist/runtime/routeIfEval.d.ts +3 -0
  78. package/dist/runtime/routeIfEval.js +94 -0
  79. package/dist/runtime/runManager.d.ts +1 -0
  80. package/dist/runtime/runManager.js +76 -0
  81. package/dist/runtime/runRetryCoordinator.js +0 -4
  82. package/dist/runtime/stageAttemptBootstrap.js +60 -11
  83. package/dist/runtime/stageRecovery.js +2 -2
  84. package/dist/runtime/stageRunner.js +6 -2
  85. package/dist/runtime/taskInput.js +1 -13
  86. package/dist/server/http.js +18 -0
  87. package/dist/tools/askOperator.d.ts +1 -0
  88. package/dist/tools/askOperator.js +14 -11
  89. package/dist/tools/emitStageEnvelope.d.ts +3 -3
  90. package/dist/tools/emitStageEnvelope.js +4 -66
  91. package/dist/tools/prepareToolArguments.d.ts +4 -0
  92. package/dist/tools/prepareToolArguments.js +79 -0
  93. package/dist/types/envelope.d.ts +0 -2
  94. package/dist/types/forkChoice.d.ts +0 -16
  95. package/dist/types/pipeline.d.ts +86 -27
  96. package/dist/types/stage.d.ts +11 -7
  97. package/dist/ui/assets/{index-D2Wt8hwH.css → index-CSsDued1.css} +1 -1
  98. package/dist/ui/assets/index-DMVdl1qB.js +119 -0
  99. package/dist/ui/index.html +2 -2
  100. package/package.json +2 -1
  101. package/skills/stageflow-author/SKILL.md +4 -4
  102. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
  103. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
  104. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
  105. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
  106. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
  107. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
  108. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
  109. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
  110. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
  111. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
  112. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
  113. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
  114. package/skills/stageflow-author/references/catalog-mapping.md +159 -50
  115. package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
  116. package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
  117. package/skills/stageflow-session-capture/SKILL.md +2 -2
  118. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
  119. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
  120. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
  121. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
  122. package/dist/envelope/cloneForks.d.ts +0 -2
  123. package/dist/envelope/cloneForks.js +0 -77
  124. 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,3 @@
1
+ import type { RouteIfPredicate } from "../types/pipeline.js";
2
+ export type RouteIfEval = "fire" | "miss" | "missing_field";
3
+ export declare function evaluateRouteIf(predicate: RouteIfPredicate, payload: Record<string, unknown> | undefined): RouteIfEval;
@@ -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 priorResult = await resolvePriorEnvelope({
140
- dag: input.dag,
141
- stageId,
142
- completedEnvelopes,
143
- store: input.store,
144
- runId: input.runId,
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 || runtimeNode?.clonable === true
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.close().catch(() => undefined);
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
- await handle.close().catch(() => undefined);
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);
@@ -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
- if (!isRecord(value)) {
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 = value.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(value.id, "id");
186
+ const id = optionalId(prepared.id, "id");
185
187
  if (kind === "free_text" || kind === "confirm") {
186
188
  return {
187
189
  kind,
188
- message: requireNonEmptyString(value.message, "message"),
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(value.artifacts)) {
195
+ if (!Array.isArray(prepared.artifacts)) {
194
196
  throw new AskOperatorError("artifacts must be an array");
195
197
  }
196
- if (value.artifacts.length < 1) {
198
+ if (prepared.artifacts.length < 1) {
197
199
  throw new AskOperatorError("artifacts must contain at least one path");
198
200
  }
199
- const artifacts = value.artifacts.map((item, index) => requireNonEmptyString(item, `artifacts[${index}]`));
201
+ const artifacts = prepared.artifacts.map((item, index) => requireNonEmptyString(item, `artifacts[${index}]`));
200
202
  return {
201
203
  kind,
202
- message: requireNonEmptyString(value.message, "message"),
204
+ message: requireNonEmptyString(prepared.message, "message"),
203
205
  artifacts,
204
206
  ...(id !== undefined ? { id } : {}),
205
207
  };
206
208
  }
207
- if (!Array.isArray(value.questions)) {
209
+ if (!Array.isArray(prepared.questions)) {
208
210
  throw new AskOperatorError("questions must be an array");
209
211
  }
210
- if (value.questions.length < 1) {
212
+ if (prepared.questions.length < 1) {
211
213
  throw new AskOperatorError("questions must contain at least one item");
212
214
  }
213
- const questions = value.questions.map((item, index) => parseSubQuestion(item, `questions[${index}]`));
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 {