stageflow 0.3.0 → 0.9.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 (164) hide show
  1. package/README.md +44 -14
  2. package/dist/agent/activity.d.ts +2 -0
  3. package/dist/agent/cursorProvider.d.ts +1 -10
  4. package/dist/agent/cursorProvider.js +18 -10
  5. package/dist/agent/fakeAgent.js +11 -7
  6. package/dist/agent/piAdapter.d.ts +2 -1
  7. package/dist/agent/piAdapter.js +28 -25
  8. package/dist/agent/port.d.ts +6 -1
  9. package/dist/agent/port.js +3 -0
  10. package/dist/cli/artifactCommand.d.ts +10 -0
  11. package/dist/cli/artifactCommand.js +120 -0
  12. package/dist/cli/envelopeCommand.d.ts +32 -0
  13. package/dist/cli/envelopeCommand.js +285 -0
  14. package/dist/cli/exportRunCommand.d.ts +10 -0
  15. package/dist/cli/exportRunCommand.js +150 -0
  16. package/dist/cli/handoffFormat.d.ts +22 -0
  17. package/dist/cli/handoffFormat.js +61 -0
  18. package/dist/cli/runCommand.d.ts +5 -1
  19. package/dist/cli/runCommand.js +36 -5
  20. package/dist/cli/runOutput.d.ts +4 -1
  21. package/dist/cli/runOutput.js +36 -8
  22. package/dist/cli/runsCommand.d.ts +22 -0
  23. package/dist/cli/runsCommand.js +591 -0
  24. package/dist/cli/skillsCommand.d.ts +16 -0
  25. package/dist/cli/skillsCommand.js +459 -0
  26. package/dist/cli/validateOutput.d.ts +2 -2
  27. package/dist/cli/validateOutput.js +2 -2
  28. package/dist/cli.js +110 -5
  29. package/dist/config/loadPipeline.js +5 -1
  30. package/dist/config/normalizePipelineStageEntry.d.ts +2 -0
  31. package/dist/config/normalizePipelineStageEntry.js +25 -1
  32. package/dist/config/pipelineStageKeys.js +1 -1
  33. package/dist/config/resolveForkEmitContext.d.ts +2 -1
  34. package/dist/config/resolveForkEmitContext.js +23 -1
  35. package/dist/config/resolvePipelineDag.js +36 -4
  36. package/dist/envelope/check.d.ts +2 -0
  37. package/dist/envelope/check.js +51 -0
  38. package/dist/envelope/cloneForks.d.ts +2 -0
  39. package/dist/envelope/cloneForks.js +62 -0
  40. package/dist/envelope/forkChoice.js +1 -1
  41. package/dist/index.d.ts +3 -1
  42. package/dist/index.js +3 -1
  43. package/dist/mcp/catalogTools.d.ts +3 -0
  44. package/dist/mcp/catalogTools.js +187 -0
  45. package/dist/mcp/controlTools.d.ts +3 -0
  46. package/dist/mcp/controlTools.js +147 -0
  47. package/dist/mcp/deps.d.ts +11 -0
  48. package/dist/mcp/deps.js +1 -0
  49. package/dist/mcp/projectRun.d.ts +1 -40
  50. package/dist/mcp/projectRun.js +1 -64
  51. package/dist/mcp/resources.d.ts +5 -0
  52. package/dist/mcp/resources.js +45 -0
  53. package/dist/mcp/server.d.ts +16 -3
  54. package/dist/mcp/server.js +159 -3
  55. package/dist/mcp/toolResults.d.ts +7 -0
  56. package/dist/mcp/toolResults.js +6 -0
  57. package/dist/mcp/tools.d.ts +2 -7
  58. package/dist/mcp/tools.js +52 -103
  59. package/dist/mcp/waitRun.d.ts +46 -0
  60. package/dist/mcp/waitRun.js +146 -0
  61. package/dist/mcp/waitingGates.d.ts +12 -0
  62. package/dist/mcp/waitingGates.js +83 -0
  63. package/dist/package-meta.d.ts +1 -0
  64. package/dist/package-meta.js +1 -0
  65. package/dist/projection/projectRun.d.ts +44 -0
  66. package/dist/projection/projectRun.js +71 -0
  67. package/dist/prompt/priorEnvelope.d.ts +1 -1
  68. package/dist/prompt/priorEnvelope.js +4 -1
  69. package/dist/runstore/paths.js +2 -0
  70. package/dist/runstore/pipelineDagSnapshot.d.ts +9 -0
  71. package/dist/runstore/pipelineDagSnapshot.js +90 -1
  72. package/dist/runstore/port.d.ts +13 -2
  73. package/dist/runstore/port.js +4 -1
  74. package/dist/runstore/runProjection.d.ts +1 -1
  75. package/dist/runstore/runProjection.js +14 -3
  76. package/dist/runstore/sqlite/SqliteRunStore.d.ts +3 -2
  77. package/dist/runstore/sqlite/SqliteRunStore.js +35 -3
  78. package/dist/runstore/stageInstanceId.d.ts +4 -0
  79. package/dist/runstore/stageInstanceId.js +23 -0
  80. package/dist/runstore/syntheticStageSnapshot.d.ts +1 -1
  81. package/dist/runstore/syntheticStageSnapshot.js +2 -1
  82. package/dist/runstore/trackProjection.js +28 -12
  83. package/dist/runstore/workspaceLayout.d.ts +1 -0
  84. package/dist/runstore/workspaceLayout.js +1 -1
  85. package/dist/runtime/cloneSchedule.d.ts +18 -0
  86. package/dist/runtime/cloneSchedule.js +233 -0
  87. package/dist/runtime/envelopeRouting.d.ts +2 -1
  88. package/dist/runtime/envelopeRouting.js +80 -13
  89. package/dist/runtime/pipelineScheduler.d.ts +6 -2
  90. package/dist/runtime/pipelineScheduler.js +166 -30
  91. package/dist/runtime/resumeReconstruct.js +8 -3
  92. package/dist/runtime/runChangeBus.d.ts +18 -0
  93. package/dist/runtime/runChangeBus.js +83 -0
  94. package/dist/runtime/runManager.d.ts +7 -0
  95. package/dist/runtime/runManager.js +31 -2
  96. package/dist/runtime/runRetryCoordinator.d.ts +2 -0
  97. package/dist/runtime/runRetryCoordinator.js +4 -1
  98. package/dist/runtime/stageAttemptBootstrap.d.ts +1 -0
  99. package/dist/runtime/stageAttemptBootstrap.js +17 -9
  100. package/dist/runtime/stageRunner.d.ts +1 -0
  101. package/dist/runtime/stageRunner.js +17 -15
  102. package/dist/runtime/stageWorker.js +11 -4
  103. package/dist/server/bootstrap.d.ts +32 -0
  104. package/dist/server/bootstrap.js +56 -0
  105. package/dist/server/createHttpHost.d.ts +34 -0
  106. package/dist/server/createHttpHost.js +70 -0
  107. package/dist/server/http.d.ts +6 -13
  108. package/dist/server/http.js +295 -374
  109. package/dist/server/mcpHost.d.ts +22 -0
  110. package/dist/server/mcpHost.js +21 -0
  111. package/dist/server/operatorResults.d.ts +13 -0
  112. package/dist/server/operatorResults.js +22 -0
  113. package/dist/tools/emitStageEnvelope.d.ts +11 -2
  114. package/dist/tools/emitStageEnvelope.js +22 -6
  115. package/dist/types/envelope.d.ts +2 -0
  116. package/dist/types/forkChoice.d.ts +22 -0
  117. package/dist/types/pipeline.d.ts +8 -0
  118. package/dist/ui/assets/{index-DefBlEvN.css → index-C3N7MXAC.css} +1 -1
  119. package/dist/ui/assets/index-CSoGDA3A.js +118 -0
  120. package/dist/ui/index.html +2 -2
  121. package/package.json +2 -1
  122. package/skills/install-suite.sh +107 -0
  123. package/skills/stageflow/SKILL.md +26 -0
  124. package/skills/stageflow/references/control-surface.md +28 -0
  125. package/skills/stageflow/scripts/detect-host.mjs +54 -0
  126. package/skills/stageflow/scripts/detect-host.test.mjs +97 -0
  127. package/skills/stageflow-author/SKILL.md +58 -0
  128. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -0
  129. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -0
  130. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +13 -0
  131. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +10 -0
  132. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +9 -0
  133. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +10 -0
  134. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +10 -0
  135. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +15 -0
  136. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +9 -0
  137. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +10 -0
  138. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +10 -0
  139. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +9 -0
  140. package/skills/stageflow-author/references/catalog-mapping.md +131 -0
  141. package/skills/stageflow-author/references/catalog-write-conventions.md +29 -0
  142. package/skills/stageflow-author/references/stage-prompt-template.md +55 -0
  143. package/skills/stageflow-author/references/validate-and-report.md +38 -0
  144. package/skills/stageflow-delegate/SKILL.md +18 -0
  145. package/skills/stageflow-delegate/references/authoring-or-run.md +15 -0
  146. package/skills/stageflow-delegate/references/example-walkthrough.md +30 -0
  147. package/skills/stageflow-delegate/references/pattern-detection.md +44 -0
  148. package/skills/stageflow-run/SKILL.md +182 -0
  149. package/skills/stageflow-run/references/mcp-call.md +17 -0
  150. package/skills/stageflow-run/references/native-question-ui.md +78 -0
  151. package/skills/stageflow-run/references/task-and-pipeline-selection.md +52 -0
  152. package/skills/stageflow-run/scripts/mcp-call.mjs +299 -0
  153. package/skills/stageflow-run/scripts/mcp-call.test.mjs +271 -0
  154. package/skills/stageflow-session-capture/SKILL.md +94 -0
  155. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +7 -0
  156. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +8 -0
  157. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +8 -0
  158. package/skills/stageflow-session-capture/references/catalog-authoring.md +40 -0
  159. package/skills/stageflow-session-capture/references/transcript-sources.md +25 -0
  160. package/skills/stageflow-session-capture/scripts/check-provider-gate.mjs +43 -0
  161. package/skills/stageflow-session-capture/scripts/locate-session-transcript.mjs +141 -0
  162. package/skills/stageflow-session-capture/scripts/resolve-catalog-id.mjs +119 -0
  163. package/skills/stageflow-setup/SKILL.md +110 -0
  164. package/dist/ui/assets/index-CFSzDZje.js +0 -118
@@ -1,9 +1,25 @@
1
1
  import { normalizeForkChoice } from "../envelope/forkChoice.js";
2
- import { collectDownstreamClosure, collectDownstreamStageIds, } from "./dagTraversal.js";
2
+ import { buildPipelineDagSnapshotFromLoaded } from "../runstore/pipelineDagSnapshot.js";
3
+ import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
4
+ import { applyCloneForksFromEnvelopes, applyCloneForksToSchedule, cloneFailureContinuesSchedule, cloneFanoutConflict, cloneRetryDownstream, cloneFailFastSkipIds, cloneScheduleAllowsRun, isCloneInstance, protectedClonableChildIds, sequentialLaterCloneIds, } from "./cloneSchedule.js";
5
+ import { collectDownstreamStageIds } from "./dagTraversal.js";
3
6
  import { buildCompletedEnvelopesFromRun, buildStageConfigById, } from "./envelopeRouting.js";
4
7
  import { WAIT_WITHOUT_WORKER_DISPATCH } from "./answerResume.js";
5
8
  import { runStage, isRunStageWaiting } from "./stageRunner.js";
6
9
  import { attemptContext } from "./stageAttemptContext.js";
10
+ function asDagSnapshot(dag) {
11
+ const snapshot = dag;
12
+ if (Array.isArray(snapshot.stage_ids))
13
+ return snapshot;
14
+ return {
15
+ ...dag,
16
+ stage_ids: dag.nodes.map((n) => n.id),
17
+ };
18
+ }
19
+ export { applyCloneForksFromEnvelopes, applyCloneForksToSchedule, };
20
+ function synthesizedFailureEnvelope(reason) {
21
+ return { status: "failure", summary: reason, artifacts: [] };
22
+ }
7
23
  export function snapshotToScheduleState(snap, overrides, stageId, executionMode) {
8
24
  const override = overrides.get(stageId);
9
25
  if (override !== undefined)
@@ -17,23 +33,28 @@ export function snapshotToScheduleState(snap, overrides, stageId, executionMode)
17
33
  return "active";
18
34
  case "waiting_for_input":
19
35
  return executionMode === "process" ? "waiting" : "active";
36
+ case "skipped":
37
+ return "skipped";
20
38
  default:
21
39
  return "pending";
22
40
  }
23
41
  }
24
42
  export async function hydrateScheduleFromStore(store, runId, dag, executionMode = "process") {
43
+ const runDetail = await store.readRun(runId);
44
+ const meta = await store.readRunMeta(runId);
45
+ const resolvedDag = meta.pipeline_dag ?? asDagSnapshot(dag);
25
46
  const states = new Map();
26
- for (const node of dag.nodes) {
47
+ for (const node of resolvedDag.nodes) {
27
48
  states.set(node.id, "pending");
28
49
  }
29
- const runDetail = await store.readRun(runId);
30
- const completedEnvelopes = await buildCompletedEnvelopesFromRun(store, runId, runDetail.stages);
50
+ const completedEnvelopes = await buildCompletedEnvelopesFromRun(store, runId, runDetail.stages, resolvedDag);
31
51
  let schedulingHalted = false;
32
52
  const overrides = new Map();
33
53
  for (const snap of runDetail.stages) {
34
54
  const state = snapshotToScheduleState(snap, overrides, snap.stage_id, executionMode);
35
55
  states.set(snap.stage_id, state);
36
- if (state === "failed") {
56
+ if (state === "failed" &&
57
+ !cloneFailureContinuesSchedule(resolvedDag, snap.stage_id, completedEnvelopes)) {
37
58
  schedulingHalted = true;
38
59
  }
39
60
  }
@@ -42,15 +63,23 @@ export async function hydrateScheduleFromStore(store, runId, dag, executionMode
42
63
  completedEnvelopes,
43
64
  schedulingHalted,
44
65
  firstFailureReason: undefined,
66
+ dag: resolvedDag,
45
67
  };
46
68
  }
47
69
  export async function hydrateScheduleForRetryRoots(store, runId, dag, retryStageIds) {
48
70
  const runDetail = await store.readRun(runId);
49
- const downstream = collectDownstreamClosure(dag, retryStageIds);
71
+ const meta = await store.readRunMeta(runId);
72
+ const resolvedDag = meta.pipeline_dag ?? asDagSnapshot(dag);
73
+ const completedEnvelopes = await buildCompletedEnvelopesFromRun(store, runId, runDetail.stages, resolvedDag);
74
+ const downstream = cloneRetryDownstream(resolvedDag, retryStageIds);
75
+ for (const rootId of retryStageIds) {
76
+ for (const id of sequentialLaterCloneIds(resolvedDag, rootId, completedEnvelopes)) {
77
+ downstream.add(id);
78
+ }
79
+ }
50
80
  const retryRootSet = new Set(retryStageIds);
51
- const completedEnvelopes = await buildCompletedEnvelopesFromRun(store, runId, runDetail.stages);
52
81
  const states = new Map();
53
- for (const node of dag.nodes) {
82
+ for (const node of resolvedDag.nodes) {
54
83
  states.set(node.id, "pending");
55
84
  }
56
85
  const overrides = new Map();
@@ -73,6 +102,7 @@ export async function hydrateScheduleForRetryRoots(store, runId, dag, retryStage
73
102
  completedEnvelopes,
74
103
  schedulingHalted: false,
75
104
  firstFailureReason: undefined,
105
+ dag: resolvedDag,
76
106
  };
77
107
  }
78
108
  export async function hydrateScheduleForRetry(store, runId, dag, retryStageId) {
@@ -80,7 +110,8 @@ export async function hydrateScheduleForRetry(store, runId, dag, retryStageId) {
80
110
  }
81
111
  export async function resumeRun(options) {
82
112
  const { prepared, resumeFromStageId, initialPrior } = options;
83
- const dag = prepared.loaded.dag;
113
+ const meta = await prepared.store.readRunMeta(prepared.run.runId);
114
+ const dag = meta.pipeline_dag ?? buildPipelineDagSnapshotFromLoaded(prepared.loaded);
84
115
  const downstream = dag.childrenOf[resumeFromStageId];
85
116
  if (downstream?.length) {
86
117
  return runPipelineDag(options);
@@ -124,7 +155,7 @@ export async function resumeRun(options) {
124
155
  }
125
156
  export async function retryRun(options) {
126
157
  const { prepared, retryRoots } = options;
127
- const hydrated = await hydrateScheduleForRetryRoots(prepared.store, prepared.run.runId, prepared.loaded.dag, [...retryRoots.keys()]);
158
+ const hydrated = await hydrateScheduleForRetryRoots(prepared.store, prepared.run.runId, buildPipelineDagSnapshotFromLoaded(prepared.loaded), [...retryRoots.keys()]);
128
159
  return runPipelineDag({
129
160
  prepared: options.prepared,
130
161
  maxActiveStagesPerRun: options.maxActiveStagesPerRun,
@@ -145,6 +176,8 @@ function applyForkChoiceToSchedule(dag, forkStageId, chosen, states, options) {
145
176
  }
146
177
  };
147
178
  for (const childId of dag.childrenOf[forkStageId] ?? []) {
179
+ if (options?.protectedIds?.has(childId))
180
+ continue;
148
181
  if (!chosen.has(childId)) {
149
182
  skipPending(childId);
150
183
  for (const desc of collectDownstreamStageIds(dag, childId)) {
@@ -166,8 +199,11 @@ export function applyForkSkipsFromEnvelopes(dag, states, completedEnvelopes) {
166
199
  }
167
200
  export function applyRetryRootDelta(dag, states, completedEnvelopes, retryRoots, stageId, attempt, clearSchedulingHalt) {
168
201
  retryRoots.set(stageId, attempt);
169
- const downstream = collectDownstreamClosure(dag, [stageId]);
202
+ const downstream = cloneRetryDownstream(dag, [stageId]);
170
203
  const resetIds = new Set([stageId, ...downstream]);
204
+ for (const id of sequentialLaterCloneIds(dag, stageId, completedEnvelopes)) {
205
+ resetIds.add(id);
206
+ }
171
207
  for (const id of resetIds) {
172
208
  const state = states.get(id);
173
209
  if (state === "active" || state === "succeeded") {
@@ -221,28 +257,28 @@ function sortRunnableIds(dag, ids) {
221
257
  const indexById = new Map(dag.nodes.map((node) => [node.id, node.stageIndex]));
222
258
  return ids.slice().sort((a, b) => (indexById.get(a) ?? 0) - (indexById.get(b) ?? 0));
223
259
  }
224
- function isRunnable(dag, stageId, states, schedulingHalted) {
260
+ function isRunnable(dag, stageId, states, schedulingHalted, completedEnvelopes) {
225
261
  if (schedulingHalted)
226
262
  return false;
227
263
  const state = states.get(stageId);
228
264
  if (state !== "pending")
229
265
  return false;
230
- const node = dag.nodes.find((n) => n.id === stageId);
231
- if (!node)
232
- return false;
233
- if (node.needs === null)
234
- return true;
235
- return states.get(node.needs) === "succeeded";
266
+ return cloneScheduleAllowsRun(dag, stageId, states, completedEnvelopes);
236
267
  }
237
268
  export async function runPipelineDag(options) {
238
269
  const { prepared, maxActiveStagesPerRun } = options;
239
270
  const executionMode = options.executionMode ?? "process";
240
271
  const { agent, task, loaded, run, store, checkoutRoot, hitl, cwd, projectRoot, } = prepared;
241
272
  const factoryCwd = projectRoot ?? cwd ?? process.cwd();
242
- const dag = loaded.dag;
273
+ const fallbackDag = buildPipelineDagSnapshotFromLoaded(loaded);
274
+ let { states, completedEnvelopes, schedulingHalted, firstFailureReason, dag: hydratedDag, } = options.initialSchedule ??
275
+ (await hydrateScheduleFromStore(store, run.runId, fallbackDag, executionMode));
276
+ let dag = hydratedDag ?? fallbackDag;
277
+ if (hydratedDag === undefined) {
278
+ const meta = await store.readRunMeta(run.runId);
279
+ dag = meta.pipeline_dag ?? fallbackDag;
280
+ }
243
281
  const stageById = buildStageConfigById(loaded);
244
- let { states, completedEnvelopes, schedulingHalted, firstFailureReason } = options.initialSchedule ??
245
- (await hydrateScheduleFromStore(store, run.runId, dag, executionMode));
246
282
  const retryContext = options.retryContext;
247
283
  const resolveLaunchAttempt = async (stageId) => {
248
284
  if (retryContext) {
@@ -293,6 +329,13 @@ export async function runPipelineDag(options) {
293
329
  }
294
330
  }
295
331
  applyForkSkipsFromEnvelopes(dag, states, completedEnvelopes);
332
+ {
333
+ const applied = applyCloneForksFromEnvelopes(dag, states, completedEnvelopes);
334
+ if (applied.dag !== dag) {
335
+ await store.updatePipelineDag(run.runId, applied.dag);
336
+ }
337
+ dag = applied.dag;
338
+ }
296
339
  let activeCount = 0;
297
340
  const inFlight = new Set();
298
341
  const notifyRetryRootTerminal = (stageId, outcome) => {
@@ -303,6 +346,20 @@ export async function runPipelineDag(options) {
303
346
  return;
304
347
  options.onRetryRootTerminal?.(stageId, attempt, outcome);
305
348
  };
349
+ const persistSkipPending = async (id) => {
350
+ if (states.get(id) !== "pending")
351
+ return;
352
+ states.set(id, "skipped");
353
+ notifyRetryRootTerminal(id, "skipped");
354
+ await store.appendStageEvent(run.runId, id, { event: "skipped" });
355
+ };
356
+ for (const [stageId, state] of states) {
357
+ if (state !== "failed")
358
+ continue;
359
+ for (const id of cloneFailFastSkipIds(dag, stageId, completedEnvelopes)) {
360
+ await persistSkipPending(id);
361
+ }
362
+ }
306
363
  const markSkippedPending = () => {
307
364
  for (const node of dag.nodes) {
308
365
  if (states.get(node.id) === "pending") {
@@ -333,9 +390,41 @@ export async function runPipelineDag(options) {
333
390
  });
334
391
  });
335
392
  };
336
- const onStageFailure = async (stageId, reason) => {
393
+ const recordCloneFailureEnvelope = async (stageId, reason, envelope) => {
394
+ if (!isCloneInstance(dag, stageId)) {
395
+ if (envelope !== undefined) {
396
+ completedEnvelopes.set(stageId, envelope);
397
+ }
398
+ return;
399
+ }
400
+ const failureEnvelope = envelope ??
401
+ completedEnvelopes.get(stageId) ??
402
+ synthesizedFailureEnvelope(reason);
403
+ completedEnvelopes.set(stageId, failureEnvelope);
404
+ if (envelope === undefined) {
405
+ try {
406
+ await store.writeEnvelope(run.runId, stageId, failureEnvelope);
407
+ }
408
+ catch {
409
+ }
410
+ }
411
+ };
412
+ const onStageFailure = async (stageId, reason, envelope) => {
337
413
  states.set(stageId, "failed");
338
414
  notifyRetryRootTerminal(stageId, "failed");
415
+ await recordCloneFailureEnvelope(stageId, reason, envelope);
416
+ if (cloneFailureContinuesSchedule(dag, stageId, completedEnvelopes)) {
417
+ if (firstFailureReason === undefined) {
418
+ firstFailureReason = reason;
419
+ }
420
+ for (const id of cloneFailFastSkipIds(dag, stageId, completedEnvelopes)) {
421
+ await persistSkipPending(id);
422
+ }
423
+ if (retryContext) {
424
+ drainRetryMutations();
425
+ }
426
+ return;
427
+ }
339
428
  if (retryContext) {
340
429
  markBranchDownstreamSkipped(stageId);
341
430
  drainRetryMutations();
@@ -352,20 +441,59 @@ export async function runPipelineDag(options) {
352
441
  firstFailureReason = reason;
353
442
  }
354
443
  };
355
- const onStageSuccess = (stageId, envelope) => {
444
+ const onStageSuccess = async (stageId, envelope) => {
445
+ const conflict = cloneFanoutConflict(dag, envelope);
446
+ if (conflict !== undefined) {
447
+ await store.appendStageEvent(run.runId, stageId, {
448
+ event: "failed",
449
+ reason: conflict,
450
+ });
451
+ await onStageFailure(stageId, conflict);
452
+ return;
453
+ }
356
454
  states.set(stageId, "succeeded");
357
455
  completedEnvelopes.set(stageId, envelope);
358
456
  notifyRetryRootTerminal(stageId, "succeeded");
359
457
  const node = dag.nodes.find((n) => n.id === stageId);
458
+ const protectedIds = protectedClonableChildIds(dag, stageId, envelope);
360
459
  if (node?.fork) {
361
460
  const chosen = normalizeForkChoice(envelope.fork_choice, "stored");
461
+ const skippedIds = [];
362
462
  applyForkChoiceToSchedule(dag, stageId, chosen, states, {
363
- onSkip: (id) => notifyRetryRootTerminal(id, "skipped"),
463
+ protectedIds,
464
+ onSkip: (id) => {
465
+ notifyRetryRootTerminal(id, "skipped");
466
+ skippedIds.push(id);
467
+ },
468
+ });
469
+ for (const id of skippedIds) {
470
+ await store.appendStageEvent(run.runId, id, { event: "skipped" });
471
+ }
472
+ }
473
+ const applied = applyCloneForksToSchedule(dag, stageId, envelope, states);
474
+ const dagChanged = applied.dag !== dag;
475
+ dag = applied.dag;
476
+ if (dagChanged) {
477
+ await store.updatePipelineDag(run.runId, dag);
478
+ }
479
+ for (const id of applied.skippedIds) {
480
+ notifyRetryRootTerminal(id, "skipped");
481
+ await store.appendStageEvent(run.runId, id, { event: "skipped" });
482
+ }
483
+ };
484
+ const handlePostSuccessError = async (stageId, reason) => {
485
+ if (states.get(stageId) === "succeeded") {
486
+ completedEnvelopes.delete(stageId);
487
+ await store.appendStageEvent(run.runId, stageId, {
488
+ event: "failed",
489
+ reason,
364
490
  });
365
491
  }
492
+ await onStageFailure(stageId, reason);
366
493
  };
367
494
  const launchStage = async (stageId) => {
368
- const stage = stageById.get(stageId);
495
+ const definitionId = definitionIdForInstance(dag, stageId);
496
+ const stage = stageById.get(definitionId);
369
497
  if (!stage) {
370
498
  await onStageFailure(stageId, `stage config missing for "${stageId}"`);
371
499
  return;
@@ -391,10 +519,11 @@ export async function runPipelineDag(options) {
391
519
  if (launchResult.type === "succeeded") {
392
520
  try {
393
521
  const envelope = await store.readEnvelope(run.runId, stageId);
394
- onStageSuccess(stageId, envelope);
522
+ await onStageSuccess(stageId, envelope);
395
523
  }
396
524
  catch (err) {
397
- await onStageFailure(stageId, err instanceof Error ? err.message : String(err));
525
+ const reason = err instanceof Error ? err.message : String(err);
526
+ await handlePostSuccessError(stageId, reason);
398
527
  }
399
528
  return;
400
529
  }
@@ -410,6 +539,7 @@ export async function runPipelineDag(options) {
410
539
  store,
411
540
  runId: run.runId,
412
541
  stage,
542
+ stageId,
413
543
  task,
414
544
  dag,
415
545
  checkoutRoot,
@@ -426,11 +556,17 @@ export async function runPipelineDag(options) {
426
556
  return;
427
557
  }
428
558
  if (!result.ok) {
429
- await onStageFailure(stageId, result.reason ?? "stage failed");
559
+ await onStageFailure(stageId, result.reason ?? "stage failed", result.envelope);
430
560
  return;
431
561
  }
432
562
  if (result.envelope) {
433
- onStageSuccess(stageId, result.envelope);
563
+ try {
564
+ await onStageSuccess(stageId, result.envelope);
565
+ }
566
+ catch (err) {
567
+ const reason = err instanceof Error ? err.message : String(err);
568
+ await handlePostSuccessError(stageId, reason);
569
+ }
434
570
  }
435
571
  else {
436
572
  states.set(stageId, "succeeded");
@@ -451,7 +587,7 @@ export async function runPipelineDag(options) {
451
587
  const pickRunnable = () => {
452
588
  const ids = [];
453
589
  for (const node of dag.nodes) {
454
- if (isRunnable(dag, node.id, states, schedulingHalted)) {
590
+ if (isRunnable(dag, node.id, states, schedulingHalted, completedEnvelopes)) {
455
591
  ids.push(node.id);
456
592
  }
457
593
  }
@@ -4,6 +4,7 @@ import { fakeHitlResumePath } from "../agent/fakeAgent.js";
4
4
  import { loadTaskFromYaml } from "../config/loadTask.js";
5
5
  import { normalizeCatalogPath } from "../runstore/normalizeCatalogPath.js";
6
6
  import { reloadPipelineForRun, reloadTaskForRun, } from "./reloadRunCatalog.js";
7
+ import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
7
8
  import { WAIT_WITHOUT_WORKER_DISPATCH } from "./answerResume.js";
8
9
  import { resumeRun } from "./pipelineScheduler.js";
9
10
  import { buildStageRoots, withResolvedAuthPath } from "./stageRoots.js";
@@ -38,7 +39,8 @@ export async function reconstructAndContinue(ctx) {
38
39
  const attemptCtx = attemptContext(latestExecution?.attempt ?? 1);
39
40
  const attemptOpt = attemptCtx.eventOptions();
40
41
  const { meta, task, loaded } = await loadRunContext(store, runId, cwd);
41
- const stageIndex = loaded.stages.findIndex((s) => s.id === stageId);
42
+ const definitionId = definitionIdForInstance(meta.pipeline_dag, stageId);
43
+ const stageIndex = loaded.stages.findIndex((s) => s.id === definitionId);
42
44
  if (stageIndex < 0) {
43
45
  const reason = `Stage ${stageId} not in pipeline ${meta.pipeline_id}`;
44
46
  await store.appendStageEvent(runId, stageId, {
@@ -49,6 +51,7 @@ export async function reconstructAndContinue(ctx) {
49
51
  return { ok: false, reason };
50
52
  }
51
53
  const stage = loaded.stages[stageIndex];
54
+ const dag = meta.pipeline_dag ?? loaded.dag;
52
55
  const workspaceDir = store.getWorkspaceDir(runId);
53
56
  const checkoutRoot = meta.checkout_root;
54
57
  const roots = withResolvedAuthPath(buildStageRoots(workspaceDir, stageId, checkoutRoot, attemptCtx), factoryCwd);
@@ -68,8 +71,9 @@ export async function reconstructAndContinue(ctx) {
68
71
  store,
69
72
  runId,
70
73
  stage,
74
+ stageId,
71
75
  task,
72
- dag: loaded.dag,
76
+ dag,
73
77
  checkoutRoot,
74
78
  workspaceDir,
75
79
  factoryCwd,
@@ -93,8 +97,9 @@ export async function reconstructAndContinue(ctx) {
93
97
  store,
94
98
  runId,
95
99
  stage,
100
+ stageId,
96
101
  task,
97
- dag: loaded.dag,
102
+ dag,
98
103
  checkoutRoot,
99
104
  workspaceDir,
100
105
  hitl: ctx.hitl,
@@ -0,0 +1,18 @@
1
+ import type { RunStore } from "../runstore/port.js";
2
+ export type RunChangeKind = "created" | "status" | "waiting";
3
+ export type RunChangeEvent = {
4
+ runId: string;
5
+ kind: RunChangeKind;
6
+ };
7
+ type RunChangeListener = (event: RunChangeEvent) => void;
8
+ export declare class RunChangeBus {
9
+ private readonly listeners;
10
+ on(listener: RunChangeListener): () => void;
11
+ off(listener: RunChangeListener): void;
12
+ emit(event: RunChangeEvent): void;
13
+ }
14
+ export declare function createRunChangeBus(): RunChangeBus;
15
+ export declare function isRunStoreWrapped(store: RunStore): boolean;
16
+ export declare function getRunChangeBusFromWrappedStore(store: RunStore): RunChangeBus | undefined;
17
+ export declare function wrapRunStoreWithChangeBus(store: RunStore, bus: RunChangeBus): RunStore;
18
+ export {};
@@ -0,0 +1,83 @@
1
+ export class RunChangeBus {
2
+ listeners = new Set();
3
+ on(listener) {
4
+ this.listeners.add(listener);
5
+ return () => {
6
+ this.listeners.delete(listener);
7
+ };
8
+ }
9
+ off(listener) {
10
+ this.listeners.delete(listener);
11
+ }
12
+ emit(event) {
13
+ for (const listener of [...this.listeners]) {
14
+ try {
15
+ listener(event);
16
+ }
17
+ catch {
18
+ // Listener isolation: one throw must not block others.
19
+ }
20
+ }
21
+ }
22
+ }
23
+ export function createRunChangeBus() {
24
+ return new RunChangeBus();
25
+ }
26
+ const WRAPPED_MARKER = Symbol.for("stageflow.wrapRunStoreWithChangeBus");
27
+ const BUS_MARKER = Symbol.for("stageflow.runChangeBus");
28
+ export function isRunStoreWrapped(store) {
29
+ return store[WRAPPED_MARKER] === true;
30
+ }
31
+ export function getRunChangeBusFromWrappedStore(store) {
32
+ const bus = store[BUS_MARKER];
33
+ return bus instanceof RunChangeBus ? bus : undefined;
34
+ }
35
+ export function wrapRunStoreWithChangeBus(store, bus) {
36
+ if (isRunStoreWrapped(store)) {
37
+ const bound = getRunChangeBusFromWrappedStore(store);
38
+ if (bound !== bus) {
39
+ throw new Error("runChangeBus does not match the bus already bound to the provided store");
40
+ }
41
+ return store;
42
+ }
43
+ const createRun = async (input) => {
44
+ const created = await store.createRun(input);
45
+ bus.emit({ runId: created.runId, kind: "created" });
46
+ return created;
47
+ };
48
+ const updateRunStatus = async (runId, status) => {
49
+ await store.updateRunStatus(runId, status);
50
+ bus.emit({ runId, kind: "status" });
51
+ };
52
+ const appendStageEvent = async (runId, stageId, event, options) => {
53
+ await store.appendStageEvent(runId, stageId, event, options);
54
+ if (event.event === "waiting_for_input" || event.event === "resumed") {
55
+ bus.emit({ runId, kind: "waiting" });
56
+ }
57
+ };
58
+ const boundCache = new Map();
59
+ return new Proxy(store, {
60
+ get(target, prop, receiver) {
61
+ if (prop === WRAPPED_MARKER)
62
+ return true;
63
+ if (prop === BUS_MARKER)
64
+ return bus;
65
+ if (prop === "createRun")
66
+ return createRun;
67
+ if (prop === "updateRunStatus")
68
+ return updateRunStatus;
69
+ if (prop === "appendStageEvent")
70
+ return appendStageEvent;
71
+ const cached = boundCache.get(prop);
72
+ if (cached !== undefined)
73
+ return cached;
74
+ const value = Reflect.get(target, prop, receiver);
75
+ if (typeof value === "function") {
76
+ const bound = value.bind(target);
77
+ boundCache.set(prop, bound);
78
+ return bound;
79
+ }
80
+ return value;
81
+ },
82
+ });
83
+ }
@@ -111,6 +111,13 @@ export declare class RunManager {
111
111
  }): Promise<StartRunResult>;
112
112
  rerun(runId: string): Promise<StartRunResult>;
113
113
  retryStage(runId: string, stageId: string): Promise<RetryStageResult>;
114
+ retryStageUntilStop(runId: string, stageId: string): Promise<{
115
+ ok: true;
116
+ pipeline: PipelineRunResult;
117
+ } | Extract<RetryStageResult, {
118
+ ok: false;
119
+ }>>;
120
+ private retryStageInternal;
114
121
  /**
115
122
  * Deliver an operator answer for `(runId, stageId)` (KTD2 / KTD6).
116
123
  * Same-process: unparks the live yield loop. After restart: reconstructs
@@ -389,6 +389,29 @@ export class RunManager {
389
389
  return this.reserveAndStartPipeline(taskYaml, pipeline, `run ${runId} task`, rerunCwd, undefined, undefined, undefined, rerunProjectRoot);
390
390
  }
391
391
  async retryStage(runId, stageId) {
392
+ return this.retryStageInternal(runId, stageId, true);
393
+ }
394
+ async retryStageUntilStop(runId, stageId) {
395
+ const retryKey = waitKey(runId, stageId);
396
+ const retried = await this.retryStageInternal(runId, stageId, false);
397
+ if (!retried.ok)
398
+ return retried;
399
+ try {
400
+ if (retried.done === undefined) {
401
+ return {
402
+ ok: false,
403
+ reason: `Retry orchestration did not start for run ${runId} stage ${stageId}`,
404
+ status: 500,
405
+ };
406
+ }
407
+ const pipeline = await retried.done;
408
+ return { ok: true, pipeline };
409
+ }
410
+ finally {
411
+ this.retryInFlight.delete(retryKey);
412
+ }
413
+ }
414
+ async retryStageInternal(runId, stageId, awaitRoot) {
392
415
  const retryKey = waitKey(runId, stageId);
393
416
  if (this.retryInFlight.has(retryKey)) {
394
417
  return {
@@ -401,7 +424,7 @@ export class RunManager {
401
424
  try {
402
425
  const orchestrationConflict = this.active.has(runId) &&
403
426
  !this.attachedWaiting.has(waitKey(runId, stageId));
404
- return await this.retryCoordinator.retryStage({
427
+ const result = await this.retryCoordinator.retryStage({
405
428
  runId,
406
429
  stageId,
407
430
  store: this.options.store,
@@ -414,10 +437,16 @@ export class RunManager {
414
437
  hitl: this.hitl,
415
438
  orchestrationConflict,
416
439
  tracking: this.retryTracking,
440
+ awaitRoot,
417
441
  });
442
+ if (!result.ok || awaitRoot !== false) {
443
+ this.retryInFlight.delete(retryKey);
444
+ }
445
+ return result;
418
446
  }
419
- finally {
447
+ catch (err) {
420
448
  this.retryInFlight.delete(retryKey);
449
+ throw err;
421
450
  }
422
451
  }
423
452
  /**
@@ -29,6 +29,7 @@ export type RetryStageResult = {
29
29
  runId: string;
30
30
  stageId: string;
31
31
  attemptIndex: number;
32
+ done?: Promise<PipelineRunResult>;
32
33
  } | {
33
34
  ok: false;
34
35
  reason: string;
@@ -62,6 +63,7 @@ export type RetryStageRequest = {
62
63
  hitl: StageHitlController;
63
64
  orchestrationConflict: boolean;
64
65
  tracking: RetryTrackingPort;
66
+ awaitRoot?: boolean;
65
67
  };
66
68
  export declare function assertStageRetryEligible(detail: RunDetail, stageId: string, opts?: {
67
69
  recoveryActive?: boolean;
@@ -315,12 +315,15 @@ export class RunRetryCoordinator {
315
315
  tracking.onOrchestrationStarted(runId, orchestrationPromise);
316
316
  }
317
317
  }
318
- await this.waitForRoot(runId, stageId, store);
318
+ if (req.awaitRoot !== false) {
319
+ await this.waitForRoot(runId, stageId, store);
320
+ }
319
321
  return {
320
322
  ok: true,
321
323
  runId,
322
324
  stageId,
323
325
  attemptIndex: execution.attempt,
326
+ done: this.getOrchestrationPromise(runId),
324
327
  };
325
328
  }
326
329
  catch (err) {
@@ -27,6 +27,7 @@ export type StageAttemptOpenInput = {
27
27
  resumeToken?: string;
28
28
  workerRoots?: boolean;
29
29
  completedEnvelopes?: Map<string, StageEnvelope>;
30
+ stageId?: string;
30
31
  };
31
32
  export type StageAttemptOpenResult = {
32
33
  ok: true;