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
@@ -10,22 +10,35 @@ function asDagSnapshot(dag) {
10
10
  stage_ids: dag.nodes.map((n) => n.id),
11
11
  };
12
12
  }
13
- /** Persistent route stages that fan out to clonable successors (or already have instances). */
13
+ function nodeById(dag, id) {
14
+ return dag.nodes.find((n) => n.id === id);
15
+ }
16
+ function isCloneEmitter(node) {
17
+ return node?.clone_array_field !== undefined;
18
+ }
19
+ function isMintedCloneInstance(node) {
20
+ return (node !== undefined &&
21
+ node.definition_id !== undefined &&
22
+ node.definition_id !== node.id);
23
+ }
24
+ function parentIsCloneEmitter(dag, node) {
25
+ if (node?.needs === null || node?.needs === undefined)
26
+ return false;
27
+ return isCloneEmitter(nodeById(dag, node.needs));
28
+ }
29
+ /** Persistent route stages that fan out to Clone Chain children (or already have instances). */
14
30
  export function routeRelatedForkParentIds(dag, routeStageIds) {
15
31
  const routeSet = new Set(routeStageIds);
16
32
  const parents = new Set();
17
33
  const snapshot = asDagSnapshot(dag);
18
34
  for (const stageId of routeStageIds) {
19
- for (const childId of dag.childrenOf[stageId] ?? []) {
20
- const child = dag.nodes.find((n) => n.id === childId);
21
- if (child?.clonable === true) {
22
- parents.add(stageId);
23
- }
35
+ if (isCloneEmitter(nodeById(dag, stageId))) {
36
+ parents.add(stageId);
24
37
  }
25
38
  }
26
39
  for (const stageId of routeStageIds) {
27
- const node = dag.nodes.find((n) => n.id === stageId);
28
- if (node?.clonable === true && node.needs !== null) {
40
+ const node = nodeById(dag, stageId);
41
+ if (node !== undefined && parentIsCloneEmitter(dag, node) && node.needs !== null) {
29
42
  parents.add(node.needs);
30
43
  }
31
44
  }
@@ -38,24 +51,25 @@ export function routeRelatedForkParentIds(dag, routeStageIds) {
38
51
  }
39
52
  }
40
53
  for (const stageId of routeStageIds) {
41
- const node = dag.nodes.find((n) => n.id === stageId);
42
- if (node?.clonable !== true)
54
+ const node = nodeById(dag, stageId);
55
+ if (!parentIsCloneEmitter(dag, node) || isMintedCloneInstance(node))
43
56
  continue;
44
57
  const instances = instancesOfDefinition(snapshot, stageId).filter((id) => id !== stageId);
45
- if (instances.length > 0 && node.needs !== null) {
58
+ if (instances.length > 0 && node !== undefined && node.needs !== null) {
46
59
  parents.add(node.needs);
47
60
  }
48
61
  }
49
62
  return [...parents];
50
63
  }
51
- /** Clone instance ids currently under clonable successors of a fork parent. */
64
+ /** Clone instance ids currently under Clone Chain children of a fork parent. */
52
65
  export function cloneInstanceIdsForForkParent(dag, forkParentStageId) {
53
66
  const snapshot = asDagSnapshot(dag);
54
67
  const ids = [];
55
68
  const seen = new Set();
69
+ const parentIsEmitter = isCloneEmitter(nodeById(dag, forkParentStageId));
56
70
  for (const childId of dag.childrenOf[forkParentStageId] ?? []) {
57
- const child = dag.nodes.find((n) => n.id === childId);
58
- if (child?.clonable === true) {
71
+ const child = nodeById(dag, childId);
72
+ if (parentIsEmitter && !isMintedCloneInstance(child)) {
59
73
  for (const id of instancesOfDefinition(snapshot, childId)) {
60
74
  if (id === childId)
61
75
  continue;
@@ -237,6 +251,22 @@ export async function selectActiveInput(options) {
237
251
  }
238
252
  return activeCohortForParent(options.store, options.runId, forkParentId);
239
253
  }
254
+ export async function forkCohortMapsFromStore(store, runId) {
255
+ const gens = await store.listForkGenerations(runId);
256
+ const supersededCloneIds = new Set();
257
+ const cohortByForkParent = new Map();
258
+ for (const gen of gens) {
259
+ if (gen.status === "superseded") {
260
+ for (const id of gen.clone_stage_ids) {
261
+ supersededCloneIds.add(id);
262
+ }
263
+ }
264
+ else if (gen.status === "active") {
265
+ cohortByForkParent.set(gen.fork_parent_stage_id, new Set(gen.clone_stage_ids));
266
+ }
267
+ }
268
+ return { supersededCloneIds, cohortByForkParent };
269
+ }
240
270
  export function filterJoinInputs(allInstanceIds, cohort) {
241
271
  if (cohort.kind === "untracked")
242
272
  return [...allInstanceIds];
@@ -264,7 +294,7 @@ export function forkChoicePreservesReplaySource(dag, chosen, sourceStageId) {
264
294
  return true;
265
295
  }
266
296
  const child = dag.nodes.find((n) => n.id === childId);
267
- if (child?.clonable === true) {
297
+ if (parentIsCloneEmitter(dag, child) && !isMintedCloneInstance(child)) {
268
298
  const snapshot = asDagSnapshot(dag);
269
299
  for (const instanceId of instancesOfDefinition(snapshot, childId)) {
270
300
  if (instanceId === childId)
@@ -0,0 +1,16 @@
1
+ import type { StageEnvelope } from "../types/envelope.js";
2
+ import type { PipelineNeedEdge, ResolvedPipelineDag, ResolvedPipelineStageNode } from "../types/pipeline.js";
3
+ import type { StageScheduleState } from "./pipelineScheduler.js";
4
+ import { type RouteIfEval } from "./routeIfEval.js";
5
+ export declare function expandJoinParent(dag: ResolvedPipelineDag, catalogParentId: string): string[];
6
+ export declare function inboundEdgeFired(edge: PipelineNeedEdge, parentId: string, states: Map<string, StageScheduleState>, envelopes: Map<string, StageEnvelope>): boolean;
7
+ export declare function joinAllowsRun(dag: ResolvedPipelineDag, stageId: string, states: Map<string, StageScheduleState>, envelopes: Map<string, StageEnvelope>): boolean;
8
+ export type InboundAfterSuccess = RouteIfEval | "ungated";
9
+ export declare function classifyInboundAfterSuccess(node: Pick<ResolvedPipelineStageNode, "needs" | "needsEdges">, parentId: string, payload: Record<string, unknown> | undefined): InboundAfterSuccess;
10
+ export declare function isEagerSingleParentIfSkip(node: Pick<ResolvedPipelineStageNode, "needs" | "needsEdges">, parentId: string, payload: Record<string, unknown> | undefined): boolean;
11
+ /**
12
+ * Finds pending multi-parent joins that cannot run once every parent is
13
+ * terminal: all-skipped, or all-succeeded with an inbound `if` miss.
14
+ * A failed parent is not a stalled skip — that join stays pending.
15
+ */
16
+ export declare function pickStalledJoinSkips(dag: ResolvedPipelineDag, states: Map<string, StageScheduleState>, completedEnvelopes?: Map<string, StageEnvelope>): string[];
@@ -0,0 +1,156 @@
1
+ import { isNeedTerminalState, predecessorEdges } from "../config/pipelineNeeds.js";
2
+ import { instancesOfDefinition } from "../runstore/pipelineDagSnapshot.js";
3
+ import { evaluateRouteIf } from "./routeIfEval.js";
4
+ function definitionInstances(dag, catalogId) {
5
+ const snapshot = dag;
6
+ if (Array.isArray(snapshot.stage_ids)) {
7
+ return instancesOfDefinition(snapshot, catalogId);
8
+ }
9
+ return dag.nodes.some((n) => n.id === catalogId) ? [catalogId] : [];
10
+ }
11
+ export function expandJoinParent(dag, catalogParentId) {
12
+ const minted = definitionInstances(dag, catalogParentId).filter((id) => id !== catalogParentId);
13
+ return minted.length > 0 ? minted : [catalogParentId];
14
+ }
15
+ function expandJoinParents(dag, edges) {
16
+ const ids = [];
17
+ for (const edge of edges) {
18
+ ids.push(...expandJoinParent(dag, edge.id));
19
+ }
20
+ return ids;
21
+ }
22
+ function joinParentDisposition(dag, edges, states) {
23
+ const instances = expandJoinParents(dag, edges);
24
+ if (instances.length === 0)
25
+ return undefined;
26
+ let hasFailed = false;
27
+ let hasSucceeded = false;
28
+ let hasSkipped = false;
29
+ for (const id of instances) {
30
+ const state = states.get(id);
31
+ if (!isNeedTerminalState(state)) {
32
+ return { allTerminal: false };
33
+ }
34
+ if (state === "failed")
35
+ hasFailed = true;
36
+ if (state === "succeeded")
37
+ hasSucceeded = true;
38
+ if (state === "skipped")
39
+ hasSkipped = true;
40
+ }
41
+ return { allTerminal: true, hasFailed, hasSucceeded, hasSkipped };
42
+ }
43
+ export function inboundEdgeFired(edge, parentId, states, envelopes) {
44
+ if (edge.if === undefined)
45
+ return true;
46
+ if (states.get(parentId) !== "succeeded")
47
+ return false;
48
+ return evaluateRouteIf(edge.if, envelopes.get(parentId)?.payload) === "fire";
49
+ }
50
+ function inboundEdgeMissed(edge, parentId, envelopes) {
51
+ if (edge.if === undefined)
52
+ return false;
53
+ return evaluateRouteIf(edge.if, envelopes.get(parentId)?.payload) === "miss";
54
+ }
55
+ function everySucceededInboundEdgeFired(dag, edges, states, envelopes) {
56
+ for (const edge of edges) {
57
+ for (const id of expandJoinParent(dag, edge.id)) {
58
+ if (!inboundEdgeFired(edge, id, states, envelopes))
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+ function anySucceededInboundEdgeMissed(dag, edges, envelopes) {
65
+ for (const edge of edges) {
66
+ for (const id of expandJoinParent(dag, edge.id)) {
67
+ if (inboundEdgeMissed(edge, id, envelopes))
68
+ return true;
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+ function multiParentJoinAllowsRun(dag, node, states, envelopes) {
74
+ const edges = predecessorEdges(node);
75
+ if (expandJoinParents(dag, edges).length < 2)
76
+ return false;
77
+ const disposition = joinParentDisposition(dag, edges, states);
78
+ if (disposition === undefined || !disposition.allTerminal)
79
+ return false;
80
+ if (disposition.hasFailed)
81
+ return false;
82
+ if (!disposition.hasSucceeded)
83
+ return false;
84
+ if (!disposition.hasSkipped) {
85
+ return everySucceededInboundEdgeFired(dag, edges, states, envelopes);
86
+ }
87
+ return true;
88
+ }
89
+ /**
90
+ * A multi-parent join whose parents are all terminal, none failed, and
91
+ * either every parent skipped, or every parent succeeded but an inbound
92
+ * `if` missed. Those joins cannot run, so the scheduler force-skips them.
93
+ * A failed parent is not stalled: the join stays pending. A still-running
94
+ * parent is not stalled either.
95
+ */
96
+ function isStalledMultiParentJoin(dag, node, states, envelopes) {
97
+ const edges = predecessorEdges(node);
98
+ if (edges.length <= 1)
99
+ return false;
100
+ const disposition = joinParentDisposition(dag, edges, states);
101
+ if (disposition === undefined || !disposition.allTerminal)
102
+ return false;
103
+ if (disposition.hasFailed)
104
+ return false;
105
+ if (!disposition.hasSucceeded)
106
+ return true;
107
+ if (disposition.hasSkipped)
108
+ return false;
109
+ return anySucceededInboundEdgeMissed(dag, edges, envelopes);
110
+ }
111
+ export function joinAllowsRun(dag, stageId, states, envelopes) {
112
+ const node = dag.nodes.find((n) => n.id === stageId);
113
+ if (!node)
114
+ return false;
115
+ const edges = predecessorEdges(node);
116
+ if (edges.length === 0)
117
+ return true;
118
+ if (expandJoinParents(dag, edges).length >= 2) {
119
+ return multiParentJoinAllowsRun(dag, node, states, envelopes);
120
+ }
121
+ const edge = edges[0];
122
+ const parentNeedId = typeof node.needs === "string" && node.needs ? node.needs : edge.id;
123
+ const parentId = expandJoinParent(dag, parentNeedId)[0];
124
+ if (states.get(parentId) !== "succeeded")
125
+ return false;
126
+ if (edge.if === undefined)
127
+ return true;
128
+ return inboundEdgeFired(edge, parentId, states, envelopes);
129
+ }
130
+ export function classifyInboundAfterSuccess(node, parentId, payload) {
131
+ const inbound = predecessorEdges(node).find((edge) => edge.id === parentId);
132
+ if (inbound?.if === undefined)
133
+ return "ungated";
134
+ return evaluateRouteIf(inbound.if, payload);
135
+ }
136
+ export function isEagerSingleParentIfSkip(node, parentId, payload) {
137
+ if (predecessorEdges(node).length !== 1)
138
+ return false;
139
+ return classifyInboundAfterSuccess(node, parentId, payload) === "miss";
140
+ }
141
+ /**
142
+ * Finds pending multi-parent joins that cannot run once every parent is
143
+ * terminal: all-skipped, or all-succeeded with an inbound `if` miss.
144
+ * A failed parent is not a stalled skip — that join stays pending.
145
+ */
146
+ export function pickStalledJoinSkips(dag, states, completedEnvelopes = new Map()) {
147
+ const ids = [];
148
+ for (const node of dag.nodes) {
149
+ if (states.get(node.id) !== "pending")
150
+ continue;
151
+ if (isStalledMultiParentJoin(dag, node, states, completedEnvelopes)) {
152
+ ids.push(node.id);
153
+ }
154
+ }
155
+ return ids;
156
+ }
@@ -3,7 +3,6 @@ import type { RunPipelineDagSnapshot, RunStore, StageSnapshot } from "../runstor
3
3
  import type { StageEnvelope } from "../types/envelope.js";
4
4
  import type { LoadedPipeline, ResolvedPipelineDag } from "../types/pipeline.js";
5
5
  import type { TaskFile } from "../types/task.js";
6
- import { applyCloneForksFromEnvelopes, applyCloneForksToSchedule } from "./cloneSchedule.js";
7
6
  import type { FeedbackLoopDecisionInput, ResolveFeedbackLoopDecisionResult } from "./feedbackLoopDecision.js";
8
7
  import type { PipelineRunResult } from "./pipelineRunner.js";
9
8
  import type { StageProcessLauncher } from "./stageProcessLauncher.js";
@@ -49,8 +48,6 @@ export type HydratedSchedule = {
49
48
  firstFailureReason: string | undefined;
50
49
  dag?: RunPipelineDagSnapshot;
51
50
  };
52
- export { applyCloneForksFromEnvelopes, applyCloneForksToSchedule, };
53
- export type { ApplyCloneForksResult } from "./cloneSchedule.js";
54
51
  export declare function snapshotToScheduleState(snap: {
55
52
  status: StageSnapshot["status"];
56
53
  }, overrides: ReadonlyMap<string, StageScheduleState>, stageId: string, executionMode?: StageExecutionMode): StageScheduleState;
@@ -1,14 +1,15 @@
1
1
  import { normalizeForkChoice } from "../envelope/forkChoice.js";
2
2
  import { buildPipelineDagSnapshotFromLoaded } from "../runstore/pipelineDagSnapshot.js";
3
3
  import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
4
- import { applyCloneForksFromEnvelopes, applyCloneForksToSchedule, cloneFailureContinuesSchedule, cloneFanoutConflict, cloneRetryDownstream, cloneFailFastSkipIds, cloneScheduleAllowsRun, failureIsAccepted, isCloneInstance, protectedClonableChildIds, sequentialLaterCloneIds, skipRejectedNeedDependents, } from "./cloneSchedule.js";
4
+ import { cloneFailureContinuesSchedule, cloneRetryDownstream, cloneFailFastSkipIds, cloneScheduleAllowsRun, failureIsAccepted, isCloneInstance, mint, protectedClonableChildIds, sequentialLaterCloneIds, skipRejectedNeedDependents, } from "./cloneSchedule.js";
5
5
  import { buildCompletedEnvelopesFromRun, buildStageConfigById, } from "./envelopeRouting.js";
6
- import { activeCohortFromCloneIds, forkChoicePreservesReplaySource, forkParentForNeedsStage, mintCohortForFanout, } from "./forkGeneration.js";
7
- import { activeReplayId, activeSourceStageId, applySendBack, cohortOverrideMap, createReplaySchedule, hydrateFromStore, isHeld, launchAttemptFor, launchFor, noteActiveCohort, onContinued, onRouteStageFailed, onRouteStageRunning, onRouteStageSucceeded, rebindRouteAttempts, resolveDecision, } from "./replayLifecycle.js";
6
+ import { classifyInboundAfterSuccess, isEagerSingleParentIfSkip, joinAllowsRun, pickStalledJoinSkips, } from "./joinReadiness.js";
7
+ import { reopenRunnableSkippedStages, persistReopenedStages } from "./reopenSkipped.js";
8
+ import { forkChoicePreservesReplaySource, forkCohortMapsFromStore, mintCohortForFanout, } from "./forkGeneration.js";
9
+ import { activeReplayId, activeSourceStageId, applySendBack, createReplaySchedule, hydrateFromStore, isHeld, launchAttemptFor, launchFor, noteActiveCohort, onContinued, onRouteStageFailed, onRouteStageRunning, onRouteStageSucceeded, rebindRouteAttempts, resolveDecision, } from "./replayLifecycle.js";
8
10
  import { WAIT_WITHOUT_WORKER_DISPATCH } from "./answerResume.js";
9
11
  import { runStage, isRunStageWaiting } from "./stageRunner.js";
10
12
  import { attemptContext, resumeSessionFilePath } from "./stageAttemptContext.js";
11
- const EMPTY_RETRY_DOWNSTREAM = new Set();
12
13
  function asDagSnapshot(dag) {
13
14
  const snapshot = dag;
14
15
  if (Array.isArray(snapshot.stage_ids))
@@ -18,7 +19,6 @@ function asDagSnapshot(dag) {
18
19
  stage_ids: dag.nodes.map((n) => n.id),
19
20
  };
20
21
  }
21
- export { applyCloneForksFromEnvelopes, applyCloneForksToSchedule, };
22
22
  function synthesizedFailureEnvelope(reason) {
23
23
  return { status: "failure", summary: reason, artifacts: [] };
24
24
  }
@@ -66,6 +66,7 @@ export async function hydrateScheduleFromStore(store, runId, dag, executionMode
66
66
  continue;
67
67
  schedulingHalted = true;
68
68
  }
69
+ await reopenRunnableSkippedStages(store, runId, resolvedDag, states, completedEnvelopes);
69
70
  return {
70
71
  states,
71
72
  completedEnvelopes,
@@ -86,15 +87,20 @@ export async function hydrateScheduleForRetryRoots(store, runId, dag, retryStage
86
87
  }
87
88
  }
88
89
  const retryRootSet = new Set(retryStageIds);
90
+ const { supersededCloneIds } = await forkCohortMapsFromStore(store, runId);
89
91
  const states = new Map();
90
92
  for (const node of resolvedDag.nodes) {
91
93
  states.set(node.id, "pending");
92
94
  }
93
95
  const overrides = new Map();
94
96
  for (const rootId of retryStageIds) {
97
+ if (supersededCloneIds.has(rootId))
98
+ continue;
95
99
  overrides.set(rootId, "pending");
96
100
  }
97
101
  for (const id of downstream) {
102
+ if (supersededCloneIds.has(id))
103
+ continue;
98
104
  overrides.set(id, "pending");
99
105
  }
100
106
  for (const snap of runDetail.stages) {
@@ -105,6 +111,20 @@ export async function hydrateScheduleForRetryRoots(store, runId, dag, retryStage
105
111
  completedEnvelopes.delete(id);
106
112
  }
107
113
  }
114
+ const skippedToReopen = [];
115
+ const skipStates = new Map();
116
+ for (const snap of runDetail.stages) {
117
+ const id = snap.stage_id;
118
+ if (supersededCloneIds.has(id))
119
+ continue;
120
+ if (snap.status !== "skipped")
121
+ continue;
122
+ if (!retryRootSet.has(id) && !downstream.has(id))
123
+ continue;
124
+ skippedToReopen.push(id);
125
+ skipStates.set(id, "skipped");
126
+ }
127
+ await persistReopenedStages(store, runId, skippedToReopen, skipStates);
108
128
  return {
109
129
  states,
110
130
  completedEnvelopes,
@@ -284,21 +304,9 @@ function isRunnable(dag, stageId, states, schedulingHalted, completedEnvelopes,
284
304
  const state = states.get(stageId);
285
305
  if (state !== "pending")
286
306
  return false;
287
- return cloneScheduleAllowsRun(dag, stageId, states, completedEnvelopes, {
288
- activeCohortForNeeds: (needsId) => {
289
- const override = feedback !== undefined ? cohortOverrideMap(feedback) : undefined;
290
- if (override === undefined || override.size === 0) {
291
- return { kind: "untracked" };
292
- }
293
- const forkParentId = forkParentForNeedsStage(dag, needsId);
294
- if (forkParentId === null)
295
- return { kind: "untracked" };
296
- const tracked = override.get(forkParentId);
297
- if (tracked === undefined)
298
- return { kind: "untracked" };
299
- return activeCohortFromCloneIds(tracked);
300
- },
301
- });
307
+ if (!joinAllowsRun(dag, stageId, states, completedEnvelopes))
308
+ return false;
309
+ return cloneScheduleAllowsRun(dag, stageId, states, completedEnvelopes);
302
310
  }
303
311
  export function hydratedScheduleHasRunnableWork(hydrated) {
304
312
  if (hydrated.schedulingHalted)
@@ -403,13 +411,6 @@ export async function runPipelineDag(options) {
403
411
  }
404
412
  }
405
413
  applyForkSkipsFromEnvelopes(dag, states, completedEnvelopes);
406
- {
407
- const applied = applyCloneForksFromEnvelopes(dag, states, completedEnvelopes);
408
- if (applied.dag !== dag) {
409
- await store.updatePipelineDag(run.runId, applied.dag);
410
- }
411
- dag = applied.dag;
412
- }
413
414
  let activeCount = 0;
414
415
  const inFlight = new Set();
415
416
  const launchedIds = new Set();
@@ -442,6 +443,28 @@ export async function runPipelineDag(options) {
442
443
  await store.appendStageEvent(run.runId, id, { event: "skipped" });
443
444
  await persistSkipRejected(id, "skipped");
444
445
  };
446
+ /**
447
+ * Finalizes multi-parent (implicit-AND) join nodes that are permanently
448
+ * stuck in `pending`: every predecessor edge has reached a terminal state,
449
+ * but `joinAllowsRun` still can't be satisfied, and terminal
450
+ * states never change back. `shouldSkipForObservedNeed` deliberately
451
+ * refuses to decide these nodes from a single resolving parent (see its
452
+ * comment in cloneSchedule.ts), so this is the only place their fate gets
453
+ * sealed -- once we're sure every sibling has actually settled. Runs once
454
+ * per scheduler tick; loops until a pass finds nothing new, so a chain of
455
+ * stalled joins settles within a single tick rather than one per ~25ms
456
+ * poll.
457
+ */
458
+ const applyStalledJoinSkips = async () => {
459
+ for (;;) {
460
+ const ids = pickStalledJoinSkips(dag, states, completedEnvelopes);
461
+ if (ids.length === 0)
462
+ return;
463
+ for (const id of ids) {
464
+ await persistSkipPending(id);
465
+ }
466
+ }
467
+ };
445
468
  for (const [stageId, state] of states) {
446
469
  if (state !== "failed")
447
470
  continue;
@@ -569,22 +592,33 @@ export async function runPipelineDag(options) {
569
592
  }
570
593
  };
571
594
  const onStageSuccess = async (stageId, envelope) => {
572
- const retryDownstreamIds = retryContext
573
- ? cloneRetryDownstream(dag, [...retryContext.retryRoots.keys()])
574
- : EMPTY_RETRY_DOWNSTREAM;
575
- const forceFreshCloneIds = activeReplayId(feedbackSchedule) !== undefined;
576
- const conflict = cloneFanoutConflict(dag, envelope, states, retryDownstreamIds, { forceFreshCloneIds });
577
- if (conflict !== undefined) {
578
- await store.appendStageEvent(run.runId, stageId, {
579
- event: "failed",
580
- reason: conflict,
581
- });
582
- await onStageFailure(stageId, conflict);
583
- return;
584
- }
585
595
  states.set(stageId, "succeeded");
586
596
  completedEnvelopes.set(stageId, envelope);
587
597
  notifyRetryRootTerminal(stageId, "succeeded");
598
+ const minted = mint(dag, stageId, envelope);
599
+ if (minted.kind === "error") {
600
+ await handlePostSuccessError(stageId, minted.reason);
601
+ return;
602
+ }
603
+ if (minted.kind === "minted") {
604
+ dag = minted.dag;
605
+ await store.updatePipelineDag(run.runId, dag);
606
+ states.delete(minted.catalogChildId);
607
+ for (const id of minted.instanceIds) {
608
+ states.set(id, "pending");
609
+ }
610
+ const replayId = activeReplayId(feedbackSchedule);
611
+ if (replayId !== undefined) {
612
+ noteActiveCohort(feedbackSchedule, stageId, minted.instanceIds);
613
+ await mintCohortForFanout({
614
+ store,
615
+ runId: run.runId,
616
+ replayId,
617
+ forkParent: stageId,
618
+ cloneStageIds: minted.instanceIds,
619
+ });
620
+ }
621
+ }
588
622
  const feedbackAction = envelope.feedback_loop;
589
623
  if (feedbackAction?.action === "send_back") {
590
624
  const node = dag.nodes.find((n) => n.id === stageId);
@@ -699,6 +733,18 @@ export async function runPipelineDag(options) {
699
733
  return;
700
734
  }
701
735
  const skippedIds = [];
736
+ // applyForkChoiceToSchedule (and everything it calls, including
737
+ // skipRejectedNeedDependents in cloneSchedule.ts) is fully synchronous
738
+ // -- no `await` anywhere in that call chain. So by the time this call
739
+ // returns, `states` already reflects every one of this stage's
740
+ // unchosen children as terminal, atomically, before the persistence
741
+ // loop below runs its first `await`. A downstream multi-parent join
742
+ // (see pickStalledJoinSkips) that depends on several of these children
743
+ // therefore always decides off fully-consistent in-memory state, even
744
+ // though its own `stage_events` row can end up persisted *between*
745
+ // two of these sequential appendStageEvent calls below (this loop) --
746
+ // that interleaving is only a SQLite write-ordering artifact, not a
747
+ // sign the join decided early.
702
748
  applyForkChoiceToSchedule(dag, stageId, chosen, states, {
703
749
  protectedIds,
704
750
  onSkip: (id) => {
@@ -710,33 +756,24 @@ export async function runPipelineDag(options) {
710
756
  await store.appendStageEvent(run.runId, id, { event: "skipped" });
711
757
  }
712
758
  }
713
- const applied = applyCloneForksToSchedule(dag, stageId, envelope, states, {
714
- forceFreshCloneIds,
715
- });
716
- const dagChanged = applied.dag !== dag;
717
- dag = applied.dag;
718
- if (dagChanged) {
719
- await store.updatePipelineDag(run.runId, dag);
720
- }
721
- for (const id of applied.skippedIds) {
722
- notifyRetryRootTerminal(id, "skipped");
723
- await store.appendStageEvent(run.runId, id, { event: "skipped" });
724
- }
725
- if (applied.mintedBySuccessor !== undefined) {
726
- for (const [, cloneStageIds] of applied.mintedBySuccessor) {
727
- if (cloneStageIds.length === 0)
728
- continue;
729
- const replayId = activeReplayId(feedbackSchedule);
730
- await mintCohortForFanout({
731
- store,
732
- runId: run.runId,
733
- ...(replayId !== undefined ? { replayId } : {}),
734
- forkParent: stageId,
735
- cloneStageIds,
736
- });
737
- noteActiveCohort(feedbackSchedule, stageId, cloneStageIds);
759
+ const childIds = dag.childrenOf[stageId] ?? [];
760
+ for (const childId of childIds) {
761
+ const child = dag.nodes.find((node) => node.id === childId);
762
+ if (!child)
763
+ continue;
764
+ const result = classifyInboundAfterSuccess(child, stageId, envelope.payload);
765
+ if (result === "missing_field") {
766
+ schedulingHalted = true;
767
+ if (firstFailureReason === undefined) {
768
+ firstFailureReason = `stage "${stageId}": route if field missing from payload`;
769
+ }
770
+ return;
771
+ }
772
+ if (isEagerSingleParentIfSkip(child, stageId, envelope.payload)) {
773
+ await persistSkipPending(childId);
738
774
  }
739
775
  }
776
+ await reopenRunnableSkippedStages(store, run.runId, dag, states, completedEnvelopes);
740
777
  };
741
778
  const handlePostSuccessError = async (stageId, reason) => {
742
779
  if (states.get(stageId) === "succeeded") {
@@ -891,6 +928,7 @@ export async function runPipelineDag(options) {
891
928
  };
892
929
  while (!allTerminal()) {
893
930
  drainRetryMutations();
931
+ await applyStalledJoinSkips();
894
932
  if (options.onLoopTick !== undefined) {
895
933
  await options.onLoopTick();
896
934
  }
@@ -0,0 +1,13 @@
1
+ import type { RunStore } from "../runstore/port.js";
2
+ import type { StageEnvelope } from "../types/envelope.js";
3
+ import type { ResolvedPipelineDag } from "../types/pipeline.js";
4
+ import type { StageScheduleState } from "./pipelineScheduler.js";
5
+ export declare function isUnchosenForkChild(dag: ResolvedPipelineDag, stageId: string, envelopes: Map<string, StageEnvelope>): boolean;
6
+ export declare function eventsShowStarted(events: ReadonlyArray<{
7
+ event: string;
8
+ }>): boolean;
9
+ export declare function shouldReopenSkippedStage(dag: ResolvedPipelineDag, stageId: string, states: Map<string, StageScheduleState>, envelopes: Map<string, StageEnvelope>, hasStarted: boolean, options?: {
10
+ supersededCloneIds?: ReadonlySet<string>;
11
+ }): boolean;
12
+ export declare function persistReopenedStages(store: Pick<RunStore, "appendStageEvent">, runId: string, stageIds: Iterable<string>, states: Map<string, StageScheduleState>): Promise<void>;
13
+ export declare function reopenRunnableSkippedStages(store: Pick<RunStore, "appendStageEvent" | "listStageEvents" | "listForkGenerations">, runId: string, dag: ResolvedPipelineDag, states: Map<string, StageScheduleState>, envelopes: Map<string, StageEnvelope>): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { normalizeForkChoice } from "../envelope/forkChoice.js";
2
+ import { forkCohortMapsFromStore } from "./forkGeneration.js";
3
+ import { joinAllowsRun } from "./joinReadiness.js";
4
+ export function isUnchosenForkChild(dag, stageId, envelopes) {
5
+ for (const node of dag.nodes) {
6
+ const envelope = envelopes.get(node.id);
7
+ if (envelope?.fork_choice === undefined || envelope.fork_choice === null) {
8
+ continue;
9
+ }
10
+ const children = dag.childrenOf[node.id] ?? [];
11
+ if (!children.includes(stageId))
12
+ continue;
13
+ const chosen = normalizeForkChoice(envelope.fork_choice, "stored");
14
+ if (!chosen.has(stageId))
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ export function eventsShowStarted(events) {
20
+ return events.some((event) => event.event === "started" || event.event === "resumed");
21
+ }
22
+ export function shouldReopenSkippedStage(dag, stageId, states, envelopes, hasStarted, options) {
23
+ if (hasStarted)
24
+ return false;
25
+ if (options?.supersededCloneIds?.has(stageId))
26
+ return false;
27
+ if (states.get(stageId) !== "skipped")
28
+ return false;
29
+ if (isUnchosenForkChild(dag, stageId, envelopes))
30
+ return false;
31
+ const hypothetical = new Map(states);
32
+ hypothetical.set(stageId, "pending");
33
+ return joinAllowsRun(dag, stageId, hypothetical, envelopes);
34
+ }
35
+ export async function persistReopenedStages(store, runId, stageIds, states) {
36
+ for (const id of stageIds) {
37
+ if (states.get(id) !== "skipped")
38
+ continue;
39
+ await store.appendStageEvent(runId, id, { event: "reopened" });
40
+ states.set(id, "pending");
41
+ }
42
+ }
43
+ export async function reopenRunnableSkippedStages(store, runId, dag, states, envelopes) {
44
+ const { supersededCloneIds } = await forkCohortMapsFromStore(store, runId);
45
+ const ids = [];
46
+ for (const [id, state] of states) {
47
+ if (state !== "skipped")
48
+ continue;
49
+ const events = await store.listStageEvents(runId, id);
50
+ if (!shouldReopenSkippedStage(dag, id, states, envelopes, eventsShowStarted(events), { supersededCloneIds })) {
51
+ continue;
52
+ }
53
+ ids.push(id);
54
+ }
55
+ await persistReopenedStages(store, runId, ids, states);
56
+ }
@@ -0,0 +1,33 @@
1
+ import type { AgentPort } from "../agent/port.js";
2
+ import type { RunStore } from "../runstore/port.js";
3
+ import type { StageExecutionMode } from "./stageConcurrency.js";
4
+ import type { StageProcessLauncher } from "./stageProcessLauncher.js";
5
+ import { StageHitlController } from "./stageHitl.js";
6
+ import { type OperatorCatalog } from "./stageAttemptBootstrap.js";
7
+ export type ResumeTimedOutContext = {
8
+ runId: string;
9
+ stageId: string;
10
+ agent: AgentPort;
11
+ store: RunStore;
12
+ hitl: StageHitlController;
13
+ executionMode: StageExecutionMode;
14
+ stageProcessLauncher?: StageProcessLauncher;
15
+ cwd: string;
16
+ factoryCwd?: string;
17
+ maxActiveStagesPerRun: number;
18
+ operatorCatalog?: OperatorCatalog;
19
+ };
20
+ export declare function assertTimedOutStageEligible(status: string, events: ReadonlyArray<{
21
+ event: string;
22
+ reason?: string;
23
+ }>): {
24
+ ok: true;
25
+ } | {
26
+ ok: false;
27
+ reason: string;
28
+ status: 409;
29
+ };
30
+ export declare function reconstructTimedOutAndContinue(ctx: ResumeTimedOutContext): Promise<{
31
+ ok: boolean;
32
+ reason?: string;
33
+ }>;