stageflow 0.16.0 → 0.18.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 (96) hide show
  1. package/README.md +2 -4
  2. package/dist/agent/claudeTools.d.ts +1 -4
  3. package/dist/agent/claudeTools.js +1 -6
  4. package/dist/agent/fakeAgent.js +4 -5
  5. package/dist/agent/piAdapter.js +1 -14
  6. package/dist/config/cloneChain.d.ts +12 -0
  7. package/dist/config/cloneChain.js +322 -0
  8. package/dist/config/createPipeline.js +101 -21
  9. package/dist/config/createStage.js +7 -0
  10. package/dist/config/loadOutcome.d.ts +1 -0
  11. package/dist/config/loadPipeline.d.ts +2 -0
  12. package/dist/config/loadPipeline.js +34 -22
  13. package/dist/config/loadStage.d.ts +2 -0
  14. package/dist/config/loadStage.js +135 -125
  15. package/dist/config/migrateYaml.js +14 -3
  16. package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
  17. package/dist/config/normalizePipelineStageEntry.js +133 -60
  18. package/dist/config/parsePreEmitChecks.js +2 -2
  19. package/dist/config/pipelineNeeds.d.ts +15 -2
  20. package/dist/config/pipelineNeeds.js +86 -4
  21. package/dist/config/pipelineRoute.d.ts +46 -0
  22. package/dist/config/pipelineRoute.js +215 -0
  23. package/dist/config/pipelineStageKeys.d.ts +2 -0
  24. package/dist/config/pipelineStageKeys.js +15 -4
  25. package/dist/config/printTargetYaml.js +18 -12
  26. package/dist/config/resolveForkEmitContext.d.ts +1 -1
  27. package/dist/config/resolveForkEmitContext.js +3 -26
  28. package/dist/config/resolvePipelineDag.d.ts +1 -3
  29. package/dist/config/resolvePipelineDag.js +143 -186
  30. package/dist/config/routeIf.d.ts +24 -0
  31. package/dist/config/routeIf.js +247 -0
  32. package/dist/config/validateCatalog.d.ts +1 -1
  33. package/dist/config/validateCatalog.js +5 -3
  34. package/dist/config/yamlDialect.d.ts +5 -8
  35. package/dist/config/yamlDialect.js +37 -29
  36. package/dist/envelope/check.d.ts +0 -2
  37. package/dist/envelope/check.js +1 -57
  38. package/dist/envelope/feedbackLoop.js +0 -3
  39. package/dist/envelope/forkChoice.js +1 -1
  40. package/dist/envelope/payloadSchema.d.ts +14 -0
  41. package/dist/envelope/payloadSchema.js +87 -2
  42. package/dist/mcp/catalogTools.js +1 -2
  43. package/dist/package-meta.d.ts +1 -1
  44. package/dist/package-meta.js +1 -1
  45. package/dist/projection/projectRun.d.ts +0 -2
  46. package/dist/projection/projectRun.js +0 -3
  47. package/dist/prompt/priorEnvelope.js +1 -1
  48. package/dist/runstore/stageInstanceId.d.ts +1 -0
  49. package/dist/runstore/stageInstanceId.js +9 -0
  50. package/dist/runtime/cloneSchedule.d.ts +16 -33
  51. package/dist/runtime/cloneSchedule.js +155 -236
  52. package/dist/runtime/envelopeRouting.js +43 -94
  53. package/dist/runtime/forkGeneration.d.ts +2 -2
  54. package/dist/runtime/forkGeneration.js +29 -15
  55. package/dist/runtime/joinReadiness.d.ts +16 -0
  56. package/dist/runtime/joinReadiness.js +156 -0
  57. package/dist/runtime/pipelineScheduler.d.ts +0 -3
  58. package/dist/runtime/pipelineScheduler.js +81 -65
  59. package/dist/runtime/routeIfEval.d.ts +3 -0
  60. package/dist/runtime/routeIfEval.js +94 -0
  61. package/dist/runtime/runRetryCoordinator.js +0 -4
  62. package/dist/runtime/stageAttemptBootstrap.js +60 -11
  63. package/dist/runtime/taskInput.js +1 -13
  64. package/dist/tools/emitStageEnvelope.d.ts +2 -3
  65. package/dist/tools/emitStageEnvelope.js +1 -65
  66. package/dist/types/envelope.d.ts +0 -2
  67. package/dist/types/forkChoice.d.ts +0 -16
  68. package/dist/types/pipeline.d.ts +86 -27
  69. package/dist/types/stage.d.ts +11 -7
  70. package/dist/ui/assets/{index-D2Wt8hwH.css → index-DsgVjLUS.css} +1 -1
  71. package/dist/ui/assets/{index-XWJovnp7.js → index-DwKGaQvQ.js} +13 -12
  72. package/dist/ui/index.html +2 -2
  73. package/package.json +2 -1
  74. package/skills/stageflow-author/SKILL.md +4 -4
  75. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
  76. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
  77. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
  78. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
  79. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
  80. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
  81. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
  82. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
  83. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
  84. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
  85. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
  86. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
  87. package/skills/stageflow-author/references/catalog-mapping.md +159 -50
  88. package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
  89. package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
  90. package/skills/stageflow-session-capture/SKILL.md +2 -2
  91. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
  92. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
  93. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
  94. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
  95. package/dist/envelope/cloneForks.d.ts +0 -2
  96. package/dist/envelope/cloneForks.js +0 -77
@@ -1,8 +1,7 @@
1
- import { isNeedTerminalState, predecessorEdges } from "../config/pipelineNeeds.js";
1
+ import { predecessorEdges } from "../config/pipelineNeeds.js";
2
2
  import { appendCloneInstances, instancesOfDefinition, } from "../runstore/pipelineDagSnapshot.js";
3
- import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
3
+ import { cloneInstanceOrdinal, definitionIdForInstance, } from "../runstore/stageInstanceId.js";
4
4
  import { collectDownstreamStageIds } from "./dagTraversal.js";
5
- import { activeCohortFromCloneIds, filterJoinInputs, } from "./forkGeneration.js";
6
5
  function asDagSnapshot(dag) {
7
6
  const snapshot = dag;
8
7
  if (Array.isArray(snapshot.stage_ids))
@@ -22,53 +21,34 @@ function definitionInstances(dag, catalogId) {
22
21
  export function isCloneInstance(dag, stageId) {
23
22
  return definitionIdForInstance(asDagSnapshot(dag), stageId) !== stageId;
24
23
  }
25
- function isParallelCloneInstance(dag, stageId, completedEnvelopes) {
24
+ function emitterCloneMode(dag, stageId) {
26
25
  if (!isCloneInstance(dag, stageId))
27
- return false;
28
- const defId = definitionIdForInstance(asDagSnapshot(dag), stageId);
29
- for (const envelope of completedEnvelopes.values()) {
30
- for (const item of envelope.clone_forks ?? []) {
31
- if (item.action === "fanout" &&
32
- item.mode === "parallel" &&
33
- item.successor_id === defId &&
34
- definitionInstances(dag, defId).includes(stageId)) {
35
- return true;
36
- }
37
- }
38
- }
39
- return false;
26
+ return undefined;
27
+ const node = dag.nodes.find((n) => n.id === stageId);
28
+ const emitterId = typeof node?.needs === "string" && node.needs ? node.needs : undefined;
29
+ if (emitterId === undefined)
30
+ return undefined;
31
+ return dag.nodes.find((n) => n.id === emitterId)?.clone_mode;
40
32
  }
41
- function isSequentialFanoutSuccessor(completedEnvelopes, successorId) {
42
- for (const envelope of completedEnvelopes.values()) {
43
- for (const item of envelope.clone_forks ?? []) {
44
- if (item.action === "fanout" &&
45
- item.mode === "sequential" &&
46
- item.successor_id === successorId) {
47
- return true;
48
- }
49
- }
50
- }
51
- return false;
33
+ function isParallelCloneInstance(dag, stageId, _completedEnvelopes) {
34
+ return emitterCloneMode(dag, stageId) === "parallel";
52
35
  }
53
36
  function isSequentialCloneInstance(dag, stageId, completedEnvelopes) {
54
- if (!isCloneInstance(dag, stageId))
55
- return false;
56
- const defId = definitionIdForInstance(asDagSnapshot(dag), stageId);
57
- if (!isSequentialFanoutSuccessor(completedEnvelopes, defId))
58
- return false;
59
- return definitionInstances(dag, defId).includes(stageId);
37
+ return sequentialInstanceList(dag, stageId, completedEnvelopes) !== undefined;
60
38
  }
61
- function sequentialInstanceList(dag, stageId, completedEnvelopes) {
62
- for (const envelope of completedEnvelopes.values()) {
63
- for (const item of envelope.clone_forks ?? []) {
64
- if (item.action !== "fanout" || item.mode !== "sequential")
65
- continue;
66
- const instances = definitionInstances(dag, item.successor_id);
67
- if (instances.includes(stageId))
68
- return instances;
69
- }
70
- }
71
- return undefined;
39
+ function sequentialInstanceList(dag, stageId, _completedEnvelopes) {
40
+ if (!isCloneInstance(dag, stageId))
41
+ return undefined;
42
+ const snapshot = asDagSnapshot(dag);
43
+ const defId = definitionIdForInstance(snapshot, stageId);
44
+ const node = dag.nodes.find((n) => n.id === stageId);
45
+ const emitterId = typeof node?.needs === "string" && node.needs ? node.needs : undefined;
46
+ if (emitterId === undefined)
47
+ return undefined;
48
+ const emitter = dag.nodes.find((n) => n.id === emitterId);
49
+ if (emitter?.clone_mode !== "sequential")
50
+ return undefined;
51
+ return definitionInstances(dag, defId);
72
52
  }
73
53
  function sequentialPreviousUnsatisfied(dag, stageId, states, completedEnvelopes) {
74
54
  const instances = sequentialInstanceList(dag, stageId, completedEnvelopes);
@@ -91,27 +71,21 @@ export function sequentialLaterCloneIds(dag, stageId, completedEnvelopes) {
91
71
  function parentNeedEdge(node, parentId, parentDefId) {
92
72
  return predecessorEdges(node).find((edge) => edge.id === parentId || edge.id === parentDefId);
93
73
  }
94
- function edgeInstancesReady(dag, edge, states) {
95
- const instances = definitionInstances(dag, edge.id);
96
- if (instances.length === 0)
97
- return false;
98
- const hasSucceededSibling = instances.some((id) => states.get(id) === "succeeded");
99
- return instances.every((id) => {
100
- const state = states.get(id);
101
- if (!isNeedTerminalState(state))
102
- return false;
103
- if (edge.on.includes(state))
104
- return true;
105
- return state === "skipped" && hasSucceededSibling;
106
- });
107
- }
108
74
  function shouldSkipForObservedNeed(dag, node, parentId, parentDefId, observed) {
75
+ // A multi-parent join is never skip-cascaded from one parent. Once every
76
+ // parent is terminal: run if any succeeded (skipped siblings do not
77
+ // block) unless every parent succeeded and an inbound `if` missed;
78
+ // stay pending if any failed; force-skip when every parent is skipped
79
+ // or every parent succeeded with an inbound miss (`pickStalledJoinSkips`).
80
+ // A single-parent node keeps eager skip-cascade below.
81
+ if (predecessorEdges(node).length > 1)
82
+ return false;
109
83
  const edge = parentNeedEdge(node, parentId, parentDefId);
110
84
  if (!edge)
111
85
  return false;
112
86
  if (edge.on.includes(observed))
113
87
  return false;
114
- if (observed === "skipped" &&
88
+ if ((observed === "skipped" || observed === "failed") &&
115
89
  predecessorEdges(node).length === 1 &&
116
90
  typeof node.needs === "string" &&
117
91
  isCloneInstance(dag, parentId)) {
@@ -164,41 +138,7 @@ function joinAndDownstreamIds(dag, cloneStageId) {
164
138
  }
165
139
  return ids;
166
140
  }
167
- /**
168
- * A fresh clone_forks fanout targeting a catalog id that already has
169
- * instances is only safe when every existing instance was put back to
170
- * `pending` by the current retry cascade (`retryDownstreamIds`) — that means
171
- * an ancestor was legitimately retried and this parent re-emitted the same
172
- * fanout for reactivation. Instances that are still active/terminal, or
173
- * merely `pending` without having gone through a retry reset (e.g. an
174
- * envelope processed twice in one live run), are rejected as before.
175
- *
176
- * Feedback-loop replay is different: the prior cohort was superseded
177
- * (`skipped`) and a new generation must mint fresh `~N` ids, so
178
- * `forceFreshCloneIds` bypasses the conflict.
179
- */
180
- export function cloneFanoutConflict(dag, envelope, states, retryDownstreamIds, options) {
181
- if (options?.forceFreshCloneIds === true)
182
- return undefined;
183
- const snapshot = asDagSnapshot(dag);
184
- for (const item of envelope.clone_forks ?? []) {
185
- if (item.action !== "fanout")
186
- continue;
187
- const existing = definitionInstances(dag, item.successor_id).filter((id) => id !== item.successor_id);
188
- if (existing.length > 0) {
189
- const reactivatable = existing.every((id) => states.get(id) === "pending" && retryDownstreamIds.has(id));
190
- if (!reactivatable) {
191
- return `clone fan-out for "${item.successor_id}" is already instanced`;
192
- }
193
- continue;
194
- }
195
- if (!snapshot.stage_ids.includes(item.successor_id)) {
196
- return `clone fan-out for "${item.successor_id}" is already instanced`;
197
- }
198
- }
199
- return undefined;
200
- }
201
- export function nextFreeCloneSuffix(snapshot, catalogId) {
141
+ function nextFreeCloneSuffix(snapshot, catalogId) {
202
142
  const prefix = `${catalogId}~`;
203
143
  let max = 0;
204
144
  for (const id of snapshot.stage_ids) {
@@ -210,15 +150,127 @@ export function nextFreeCloneSuffix(snapshot, catalogId) {
210
150
  }
211
151
  return max + 1;
212
152
  }
213
- export function protectedClonableChildIds(dag, predecessorId, envelope) {
153
+ function catalogCloneChildId(dag, emitterId) {
154
+ const catalogChildId = (dag.childrenOf[emitterId] ?? []).find((id) => {
155
+ const child = dag.nodes.find((n) => n.id === id);
156
+ return child !== undefined && (child.definition_id ?? child.id) === child.id;
157
+ });
158
+ if (catalogChildId !== undefined)
159
+ return catalogChildId;
160
+ const instanceChild = (dag.childrenOf[emitterId] ?? []).find((id) => {
161
+ const child = dag.nodes.find((n) => n.id === id);
162
+ return child?.definition_id !== undefined && child.definition_id !== child.id;
163
+ });
164
+ return instanceChild
165
+ ? dag.nodes.find((n) => n.id === instanceChild)?.definition_id
166
+ : undefined;
167
+ }
168
+ export function mint(dag, emitterId, envelope) {
169
+ const emitter = dag.nodes.find((n) => n.id === emitterId);
170
+ const field = emitter?.clone_array_field;
171
+ if (field === undefined)
172
+ return { kind: "none" };
173
+ const catalogChildId = catalogCloneChildId(dag, emitterId);
174
+ if (catalogChildId === undefined)
175
+ return { kind: "none" };
176
+ const arr = envelope.payload?.[field];
177
+ if (!Array.isArray(arr) || arr.length < 1) {
178
+ return {
179
+ kind: "error",
180
+ reason: `Clone Array "${field}" must contain at least one item`,
181
+ };
182
+ }
183
+ const snapshot = asDagSnapshot(dag);
184
+ const { snapshot: next, instanceIds } = appendCloneInstances(snapshot, {
185
+ catalogId: catalogChildId,
186
+ predecessorId: emitterId,
187
+ count: arr.length,
188
+ startAt: nextFreeCloneSuffix(snapshot, catalogChildId),
189
+ });
190
+ return {
191
+ kind: "minted",
192
+ dag: next,
193
+ catalogChildId,
194
+ instanceIds,
195
+ };
196
+ }
197
+ function cloneAssignmentIndex(dag, stageId, definitionId, arrayLength) {
198
+ const ordinal = cloneInstanceOrdinal(stageId, definitionId);
199
+ if (ordinal === undefined)
200
+ return undefined;
201
+ const snapshot = dag;
202
+ if (!Array.isArray(snapshot.stage_ids) || arrayLength < 1) {
203
+ return ordinal - 1;
204
+ }
205
+ const siblings = instancesOfDefinition(snapshot, definitionId)
206
+ .map((id) => ({ id, ordinal: cloneInstanceOrdinal(id, definitionId) }))
207
+ .filter((row) => row.ordinal !== undefined)
208
+ .sort((a, b) => a.ordinal - b.ordinal);
209
+ const cohort = siblings.slice(-arrayLength);
210
+ const index = cohort.findIndex((row) => row.id === stageId);
211
+ return index === -1 ? undefined : index;
212
+ }
213
+ export function assignment(dag, stageId, definitionId, completedEnvelopes) {
214
+ const ordinal = cloneInstanceOrdinal(stageId, definitionId);
215
+ if (ordinal === undefined)
216
+ return undefined;
217
+ const node = dag.nodes.find((n) => n.id === stageId);
218
+ if (!node)
219
+ return undefined;
220
+ const emitterId = typeof node.needs === "string" && node.needs
221
+ ? node.needs
222
+ : predecessorEdges(node)[0]?.id;
223
+ if (emitterId === undefined)
224
+ return undefined;
225
+ const emitter = dag.nodes.find((n) => n.id === emitterId);
226
+ const field = emitter?.clone_array_field;
227
+ if (field === undefined)
228
+ return undefined;
229
+ const parent = completedEnvelopes.get(emitterId);
230
+ if (parent === undefined || parent.status !== "success") {
231
+ return {
232
+ ok: false,
233
+ reason: `missing envelope for Clone Chain emitter "${emitterId}"`,
234
+ };
235
+ }
236
+ const arr = parent.payload?.[field];
237
+ if (!Array.isArray(arr)) {
238
+ return {
239
+ ok: false,
240
+ reason: `missing Clone Array element ${ordinal} on "${emitterId}"`,
241
+ };
242
+ }
243
+ const index = cloneAssignmentIndex(dag, stageId, definitionId, arr.length);
244
+ if (index === undefined || index >= arr.length) {
245
+ return {
246
+ ok: false,
247
+ reason: `missing Clone Array element ${ordinal} on "${emitterId}"`,
248
+ };
249
+ }
250
+ const element = arr[index];
251
+ if (element === null || typeof element !== "object" || Array.isArray(element)) {
252
+ return {
253
+ ok: false,
254
+ reason: `Clone Array element ${index + 1} is not an object`,
255
+ };
256
+ }
257
+ return {
258
+ ok: true,
259
+ prior: {
260
+ status: "success",
261
+ summary: parent.summary,
262
+ artifacts: [],
263
+ payload: structuredClone(element),
264
+ },
265
+ };
266
+ }
267
+ export function protectedClonableChildIds(dag, predecessorId, _envelope) {
214
268
  const ids = new Set();
269
+ const predecessor = dag.nodes.find((n) => n.id === predecessorId);
270
+ if (predecessor?.clone_array_field === undefined)
271
+ return ids;
215
272
  for (const childId of dag.childrenOf[predecessorId] ?? []) {
216
- const child = dag.nodes.find((n) => n.id === childId);
217
- if (child?.clonable === true)
218
- ids.add(childId);
219
- }
220
- for (const item of envelope.clone_forks ?? []) {
221
- ids.add(item.successor_id);
273
+ ids.add(childId);
222
274
  }
223
275
  return ids;
224
276
  }
@@ -276,142 +328,9 @@ export function cloneFailFastSkipIds(dag, stageId, completedEnvelopes) {
276
328
  ids.push(...joinAndDownstreamIds(dag, stageId));
277
329
  return ids;
278
330
  }
279
- export function cloneScheduleAllowsRun(dag, stageId, states, completedEnvelopes, options) {
280
- const node = dag.nodes.find((n) => n.id === stageId);
281
- if (!node)
282
- return false;
331
+ export function cloneScheduleAllowsRun(dag, stageId, states, completedEnvelopes) {
283
332
  if (definitionInstances(dag, stageId).some((id) => id !== stageId)) {
284
333
  return false;
285
334
  }
286
- const edges = predecessorEdges(node);
287
- if (edges.length === 0) {
288
- return !sequentialPreviousUnsatisfied(dag, stageId, states, completedEnvelopes);
289
- }
290
- if (edges.length >= 2) {
291
- for (const edge of edges) {
292
- if (!edgeInstancesReady(dag, edge, states))
293
- return false;
294
- }
295
- }
296
- else {
297
- const parentNeedId = typeof node.needs === "string" && node.needs ? node.needs : edges[0].id;
298
- const allInstances = definitionInstances(dag, parentNeedId);
299
- const cohort = options?.activeCohortForNeeds?.(parentNeedId) ??
300
- activeCohortFromCloneIds(options?.activeCloneIdsForNeeds?.(parentNeedId));
301
- if (cohort.kind === "awaiting_mint") {
302
- return false;
303
- }
304
- const instances = filterJoinInputs(allInstances, cohort);
305
- if (instances.length <= 1) {
306
- const parentId = instances[0] ?? parentNeedId;
307
- if (states.get(parentId) !== "succeeded")
308
- return false;
309
- }
310
- else if (cohort.kind === "active") {
311
- if (!instances.every((id) => states.get(id) === "succeeded")) {
312
- return false;
313
- }
314
- }
315
- else if (!instances.every((id) => states.get(id) === "succeeded" || states.get(id) === "skipped")) {
316
- return false;
317
- }
318
- }
319
335
  return !sequentialPreviousUnsatisfied(dag, stageId, states, completedEnvelopes);
320
336
  }
321
- export function applyCloneForksToSchedule(dag, predecessorId, envelope, states, options) {
322
- let current = asDagSnapshot(dag);
323
- const skippedIds = [];
324
- const mintedBySuccessor = new Map();
325
- const forceFresh = options?.forceFreshCloneIds === true;
326
- const skipPending = (stageId) => {
327
- if (states.get(stageId) === "pending") {
328
- states.set(stageId, "skipped");
329
- skippedIds.push(stageId);
330
- }
331
- };
332
- for (const item of envelope.clone_forks ?? []) {
333
- if (item.action === "skip") {
334
- skipPending(item.successor_id);
335
- skipRejectedNeedDependents(current, item.successor_id, states, "skipped", skipPending);
336
- continue;
337
- }
338
- if (item.action !== "fanout")
339
- continue;
340
- const existing = definitionInstances(current, item.successor_id).filter((id) => id !== item.successor_id);
341
- if (forceFresh) {
342
- const { snapshot, instanceIds } = appendCloneInstances(current, {
343
- catalogId: item.successor_id,
344
- predecessorId,
345
- count: item.clones.length,
346
- startAt: nextFreeCloneSuffix(current, item.successor_id),
347
- });
348
- current = snapshot;
349
- states.delete(item.successor_id);
350
- for (const id of instanceIds) {
351
- states.set(id, "pending");
352
- }
353
- mintedBySuccessor.set(item.successor_id, instanceIds);
354
- continue;
355
- }
356
- if (existing.length > 0) {
357
- // Reactivating instances a retry cascade reset to `pending` (see
358
- // cloneFanoutConflict), which only allows this fanout through when
359
- // *every* existing instance is pending. Anything short of that —
360
- // a normal resume re-apply of an already-processed fanout, or only
361
- // one sibling out of a cohort being retried directly — must stay a
362
- // no-op here: growing/shrinking the cohort in those cases would mint
363
- // or skip instances no retry actually asked for.
364
- const allPending = existing.every((id) => states.get(id) === "pending");
365
- if (!allPending)
366
- continue;
367
- const desired = item.clones.length;
368
- for (const id of existing.slice(desired)) {
369
- skipPending(id);
370
- skipRejectedNeedDependents(current, id, states, "skipped", skipPending);
371
- }
372
- if (desired > existing.length) {
373
- const { snapshot, instanceIds } = appendCloneInstances(current, {
374
- catalogId: item.successor_id,
375
- predecessorId,
376
- count: desired - existing.length,
377
- startAt: nextFreeCloneSuffix(current, item.successor_id),
378
- });
379
- current = snapshot;
380
- for (const id of instanceIds) {
381
- states.set(id, "pending");
382
- }
383
- }
384
- continue;
385
- }
386
- const { snapshot, instanceIds } = appendCloneInstances(current, {
387
- catalogId: item.successor_id,
388
- predecessorId,
389
- count: item.clones.length,
390
- });
391
- current = snapshot;
392
- states.delete(item.successor_id);
393
- for (const id of instanceIds) {
394
- states.set(id, "pending");
395
- }
396
- mintedBySuccessor.set(item.successor_id, instanceIds);
397
- }
398
- return {
399
- dag: current,
400
- skippedIds,
401
- ...(mintedBySuccessor.size > 0 ? { mintedBySuccessor } : {}),
402
- };
403
- }
404
- export function applyCloneForksFromEnvelopes(dag, states, completedEnvelopes) {
405
- let current = asDagSnapshot(dag);
406
- const skippedIds = [];
407
- const predecessors = [...current.nodes];
408
- for (const node of predecessors) {
409
- const envelope = completedEnvelopes.get(node.id);
410
- if (!envelope?.clone_forks?.length)
411
- continue;
412
- const applied = applyCloneForksToSchedule(current, node.id, envelope, states);
413
- current = applied.dag;
414
- skippedIds.push(...applied.skippedIds);
415
- }
416
- return { dag: current, skippedIds };
417
- }
@@ -1,14 +1,7 @@
1
1
  import { predecessorEdges } from "../config/pipelineNeeds.js";
2
- import { instancesOfDefinition, } from "../runstore/pipelineDagSnapshot.js";
3
2
  import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
4
3
  import { activeCohortFromCloneIds, filterJoinInputs, selectActiveInput, } from "./forkGeneration.js";
5
- function definitionInstances(dag, catalogId) {
6
- const snapshot = dag;
7
- if (Array.isArray(snapshot.stage_ids)) {
8
- return instancesOfDefinition(snapshot, catalogId);
9
- }
10
- return dag.nodes.some((n) => n.id === catalogId) ? [catalogId] : [];
11
- }
4
+ import { expandJoinParent } from "./joinReadiness.js";
12
5
  async function resolveActiveCohortForNeeds(dag, needsId, store, runId, activeCloneIds, scheduleOverride) {
13
6
  if (activeCloneIds !== undefined) {
14
7
  return activeCohortFromCloneIds(activeCloneIds);
@@ -59,29 +52,6 @@ export function buildStageConfigById(loaded) {
59
52
  function dagNode(dag, stageId) {
60
53
  return dag.nodes.find((node) => node.id === stageId);
61
54
  }
62
- const SYNTHETIC_SKIPPED_TERMINAL = {
63
- status: "skipped",
64
- summary: "stage was skipped",
65
- artifacts: [],
66
- };
67
- function mintedCloneInstances(dag, parentId) {
68
- return definitionInstances(dag, parentId).filter((id) => id !== parentId);
69
- }
70
- function isClonableParent(dag, parentId) {
71
- const node = dagNode(dag, parentId);
72
- return node?.clonable === true || mintedCloneInstances(dag, parentId).length > 0;
73
- }
74
- function failureReasonFromSnapshot(snap) {
75
- let reason = "stage failed";
76
- if (snap === undefined)
77
- return reason;
78
- for (const ev of snap.events) {
79
- if (ev.event === "failed" && typeof ev.reason === "string" && ev.reason) {
80
- reason = ev.reason;
81
- }
82
- }
83
- return reason;
84
- }
85
55
  async function loadStageSnapshots(options) {
86
56
  if (options.stages !== undefined)
87
57
  return options.stages;
@@ -107,24 +77,10 @@ async function readCachedEnvelope(options, snapshots, stageId) {
107
77
  }
108
78
  return undefined;
109
79
  }
110
- async function terminalForPersistedStage(options, snapshots, stageId) {
80
+ async function successTerminalForPersistedStage(options, snapshots, stageId) {
111
81
  const snap = snapshots.get(stageId);
112
- if (snap?.status === "skipped") {
113
- return { ok: true, value: { ...SYNTHETIC_SKIPPED_TERMINAL } };
114
- }
115
- if (snap?.status === "failed") {
116
- const envelope = await readCachedEnvelope(options, snapshots, stageId);
117
- if (envelope?.status === "failure") {
118
- return { ok: true, value: envelope };
119
- }
120
- return {
121
- ok: true,
122
- value: {
123
- status: "failure",
124
- summary: failureReasonFromSnapshot(snap),
125
- artifacts: [],
126
- },
127
- };
82
+ if (snap?.status === "skipped" || snap?.status === "failed") {
83
+ return { ok: true, omit: true };
128
84
  }
129
85
  if (snap?.status === "succeeded") {
130
86
  const envelope = await readCachedEnvelope(options, snapshots, stageId);
@@ -134,8 +90,18 @@ async function terminalForPersistedStage(options, snapshots, stageId) {
134
90
  reason: `missing envelope for upstream stage "${stageId}"`,
135
91
  };
136
92
  }
93
+ if (envelope.status !== "success") {
94
+ return { ok: true, omit: true };
95
+ }
137
96
  return { ok: true, value: envelope };
138
97
  }
98
+ const fromMap = options.completedEnvelopes.get(stageId);
99
+ if (fromMap?.status === "success") {
100
+ return { ok: true, value: structuredClone(fromMap) };
101
+ }
102
+ if (fromMap !== undefined) {
103
+ return { ok: true, omit: true };
104
+ }
139
105
  return {
140
106
  ok: false,
141
107
  reason: `missing envelope for upstream stage "${stageId}"`,
@@ -146,32 +112,27 @@ async function resolveGenericJoinPriors(options, node) {
146
112
  const priorEnvelopesByStage = {};
147
113
  for (const edge of predecessorEdges(node)) {
148
114
  const parentId = edge.id;
149
- if (isClonableParent(options.dag, parentId)) {
150
- const minted = mintedCloneInstances(options.dag, parentId);
151
- if (minted.length > 0) {
152
- const list = [];
153
- for (const id of minted) {
154
- const terminal = await terminalForPersistedStage(options, snapshots, id);
155
- if (!terminal.ok)
156
- return terminal;
157
- list.push(terminal.value);
158
- }
159
- priorEnvelopesByStage[parentId] = list;
160
- continue;
115
+ const expanded = expandJoinParent(options.dag, parentId);
116
+ if (expanded.length > 1 || expanded[0] !== parentId) {
117
+ const list = [];
118
+ for (const id of expanded) {
119
+ const terminal = await successTerminalForPersistedStage(options, snapshots, id);
120
+ if (!terminal.ok)
121
+ return terminal;
122
+ if ("omit" in terminal)
123
+ continue;
124
+ list.push(terminal.value);
161
125
  }
162
- if (snapshots.get(parentId)?.status === "skipped") {
163
- priorEnvelopesByStage[parentId] = [];
164
- continue;
126
+ if (list.length > 0) {
127
+ priorEnvelopesByStage[parentId] = list;
165
128
  }
166
- const once = await terminalForPersistedStage(options, snapshots, parentId);
167
- if (!once.ok)
168
- return once;
169
- priorEnvelopesByStage[parentId] = [once.value];
170
129
  continue;
171
130
  }
172
- const terminal = await terminalForPersistedStage(options, snapshots, parentId);
131
+ const terminal = await successTerminalForPersistedStage(options, snapshots, parentId);
173
132
  if (!terminal.ok)
174
133
  return terminal;
134
+ if ("omit" in terminal)
135
+ continue;
175
136
  priorEnvelopesByStage[parentId] = terminal.value;
176
137
  }
177
138
  return { ok: true, prior: null, priorEnvelopesByStage };
@@ -192,21 +153,30 @@ export async function resolvePriorEnvelope(options) {
192
153
  return resolveGenericJoinPriors(options, node);
193
154
  }
194
155
  const parentId = typeof node.needs === "string" && node.needs ? node.needs : edges[0].id;
195
- const allJoinInstances = definitionInstances(options.dag, parentId);
196
- if (allJoinInstances.length > 1) {
156
+ const expanded = expandJoinParent(options.dag, parentId);
157
+ if (expanded.length > 1 || expanded[0] !== parentId) {
197
158
  const cohort = await resolveActiveCohortForNeeds(options.dag, parentId, options.store, options.runId, options.activeCloneIds, options.scheduleOverride);
198
- const joinInstances = filterJoinInputs(allJoinInstances, cohort);
159
+ const joinInstances = filterJoinInputs(expanded, cohort);
160
+ const snapshots = new Map((await loadStageSnapshots(options)).map((snap) => [snap.stage_id, snap]));
199
161
  const joinPriors = [];
200
162
  for (const id of joinInstances) {
163
+ const snap = snapshots.get(id);
164
+ if (snap?.status === "skipped" || snap?.status === "failed") {
165
+ continue;
166
+ }
201
167
  const fromMap = options.completedEnvelopes.get(id);
202
168
  if (fromMap !== undefined) {
203
- joinPriors.push(structuredClone(fromMap));
169
+ if (fromMap.status === "success") {
170
+ joinPriors.push(structuredClone(fromMap));
171
+ }
204
172
  continue;
205
173
  }
206
174
  if (options.store !== undefined && options.runId !== undefined) {
207
175
  try {
208
176
  const envelope = await options.store.readEnvelope(options.runId, id);
209
- joinPriors.push(structuredClone(envelope));
177
+ if (envelope.status === "success") {
178
+ joinPriors.push(structuredClone(envelope));
179
+ }
210
180
  continue;
211
181
  }
212
182
  catch {
@@ -242,26 +212,5 @@ export async function resolvePriorEnvelope(options) {
242
212
  reason: `missing envelope for upstream stage "${parentId}"`,
243
213
  };
244
214
  }
245
- for (const item of parent.clone_forks ?? []) {
246
- if (item.action === "once" && item.successor_id === options.stageId) {
247
- return { ok: true, prior: structuredClone(item.envelope) };
248
- }
249
- if (item.action === "fanout") {
250
- const allInstanceIds = definitionInstances(options.dag, item.successor_id);
251
- const cohort = await resolveActiveCohortForNeeds(options.dag, item.successor_id, options.store, options.runId, options.activeCloneIds, options.scheduleOverride);
252
- const instanceIds = filterJoinInputs(allInstanceIds, cohort);
253
- const index = instanceIds.indexOf(options.stageId);
254
- if (index >= 0) {
255
- const clone = item.clones[index];
256
- if (clone === undefined) {
257
- return {
258
- ok: false,
259
- reason: `missing clone envelope for instance "${options.stageId}"`,
260
- };
261
- }
262
- return { ok: true, prior: structuredClone(clone.envelope) };
263
- }
264
- }
265
- }
266
215
  return { ok: true, prior: structuredClone(parent) };
267
216
  }
@@ -1,8 +1,8 @@
1
1
  import type { ForkGenerationRecord, RunStore } from "../runstore/port.js";
2
2
  import type { ResolvedPipelineDag } from "../types/pipeline.js";
3
- /** Persistent route stages that fan out to clonable successors (or already have instances). */
3
+ /** Persistent route stages that fan out to Clone Chain children (or already have instances). */
4
4
  export declare function routeRelatedForkParentIds(dag: ResolvedPipelineDag, routeStageIds: readonly string[]): string[];
5
- /** Clone instance ids currently under clonable successors of a fork parent. */
5
+ /** Clone instance ids currently under Clone Chain children of a fork parent. */
6
6
  export declare function cloneInstanceIdsForForkParent(dag: ResolvedPipelineDag, forkParentStageId: string): string[];
7
7
  export declare function backfillActiveGenerationIfNeeded(store: RunStore, runId: string, options: {
8
8
  dag: ResolvedPipelineDag;