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
package/README.md CHANGED
@@ -31,13 +31,12 @@ The same pipeline runs three ways without rewriting anything:
31
31
  - **Path-based CLI** — `--pipeline` and `--task` take filesystem paths (no bare-id lookup)
32
32
  - **Pi-native** — runs on `@earendil-works/pi-coding-agent`; reuse an existing Pi login (`pi_home`) or store credentials in `~/.stageflow/agent/auth.json` (`sf_owned`)
33
33
  - **Envelope handoffs** — typed stage payloads and artifacts via `write_stage_artifact` / `emit_stage_envelope`
34
- - **Verified stage execution** — pipeline-owned completion checks with durable evidence, automatic repair, and manual recovery ([guide](docs/verified-stage-execution.md))
34
+ - **Verified stage execution** — after-phase `verify` checks with durable evidence, plus `on_verify_fail` repair or manual operator recovery ([guide](docs/verified-stage-execution.md))
35
35
  - **HITL gates** — operator questions in the console; CI exits `2` when a run is waiting
36
36
  - **Operator console** — triage runs, connect providers, answer gates, inspect transcripts at `http://127.0.0.1:3847`
37
37
  - **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` or `sf mcp` is running
38
38
  - **CI / headless** — `sf validate --strict --json`, `sf run --json` with exit codes `0` / `1` / `2`
39
- - **Parallel stages** — pipeline DAG with fan-out and join (see [YAML catalog](docs/yaml-catalog.md))
40
- - **Clonable fan-out** — clone one successor N times at completion, then join (see [YAML catalog](docs/yaml-catalog.md#clonable-successors))
39
+ - **Parallel stages** — pipeline DAG with fan-out, join, and Clone Chains (one Clone Instance per Clone Array element; see [YAML catalog](docs/yaml-catalog.md#clone-chain))
41
40
  - **SQLite run store** — `<git-root>/.stageflow/` state plus per-run workspaces under `.stageflow/runs/`
42
41
 
43
42
  ## Architecture at a glance
@@ -219,7 +218,6 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
219
218
  | [hello-world](examples/hello-world/) | Single stage, domain-neutral |
220
219
  | [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
221
220
  | [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
222
- | [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
223
221
  | [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
224
222
  | [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
225
223
  | [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
@@ -74,6 +74,10 @@ export type StageLifecycleEvent = {
74
74
  } | {
75
75
  event: "skipped";
76
76
  }
77
+ /** A previously skipped stage is runnable again (parent succeeded). */
78
+ | {
79
+ event: "reopened";
80
+ }
77
81
  /** Operator explicitly authorized a new attempt after verified failure. */
78
82
  | {
79
83
  event: "manual_recovery_requested";
@@ -47,6 +47,7 @@ import { readActivityVerbose } from "./activity.js";
47
47
  import { composeStageUserPrompt } from "./piAdapter.js";
48
48
  import { claudeSessionMarkerPath, clearClaudeSessionMarker, readClaudeSessionMarker, writeClaudeSessionMarker, } from "./claudeSession.js";
49
49
  import { DEFAULT_STAGE_TIMEOUT_MS, runStageViaOpen, runtimeStageId, } from "./port.js";
50
+ import { composeTimeoutResumePrompt, stageTimeoutReason, } from "./stageTimeout.js";
50
51
  import { StageMcpError } from "../config/resolveStageMcpServers.js";
51
52
  import { addModelUsage, emptyStageUsage } from "../types/usage.js";
52
53
  /** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
@@ -295,6 +296,29 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId, us
295
296
  await clearClaudeSessionMarker(markerPath);
296
297
  return { kind: "completed", result: { ok: true, envelope: emitCapture.envelope, usage } };
297
298
  }
299
+ if (controller.signal.aborted && sessionId !== undefined) {
300
+ await writeClaudeSessionMarker(markerPath, { sessionId, usage });
301
+ return {
302
+ kind: "completed",
303
+ result: {
304
+ ok: false,
305
+ reason: stageTimeoutReason(timeoutMs),
306
+ envelope: emitCapture.envelope,
307
+ usage,
308
+ },
309
+ };
310
+ }
311
+ if (controller.signal.aborted) {
312
+ return {
313
+ kind: "completed",
314
+ result: {
315
+ ok: false,
316
+ reason: stageTimeoutReason(timeoutMs),
317
+ envelope: emitCapture.envelope,
318
+ usage,
319
+ },
320
+ };
321
+ }
298
322
  return {
299
323
  kind: "completed",
300
324
  result: {
@@ -385,6 +409,20 @@ export class ClaudeAgentAdapter {
385
409
  if (!pre.ok) {
386
410
  return { status: "completed", result: { ok: false, reason: pre.reason } };
387
411
  }
412
+ if (input.sessionMode === "timeout_resume") {
413
+ const marker = await readClaudeSessionMarker(markerPath);
414
+ if (marker?.sessionId === undefined) {
415
+ return {
416
+ status: "completed",
417
+ result: {
418
+ ok: false,
419
+ reason: "missing Claude session to resume; use retry to start a new attempt",
420
+ },
421
+ };
422
+ }
423
+ const outcome = await runTurn(input, pre.model, markerPath, composeTimeoutResumePrompt(), marker.sessionId, usage);
424
+ return applyOutcome(outcome);
425
+ }
388
426
  const outcome = await runTurn(input, pre.model, markerPath, composeStageUserPrompt(input, EMIT_STAGE_ENVELOPE_TOOL_NAME, undefined, WRITE_STAGE_ARTIFACT_TOOL_NAME), undefined, usage);
389
427
  return applyOutcome(outcome);
390
428
  },
@@ -45,10 +45,8 @@ export type AskOperatorCapture = {
45
45
  * nothing else catches one drifting from the other.
46
46
  *
47
47
  * Field presence mirrors the typebox schema's own conditionality:
48
- * `clone_forks` only exists when a stage can clone-fork at all, and
49
48
  * `fork_choice` is required (not just present) when the stage has a fork
50
- * to route. Building this statically would silently offer the model a
51
- * `clone_forks` field on stages that have nowhere to route one.
49
+ * to route.
52
50
  */
53
51
  export declare function buildEmitStageEnvelopeShape(options: {
54
52
  forkEmitContext?: ForkEmitContext;
@@ -62,7 +60,6 @@ export declare function buildEmitStageEnvelopeShape(options: {
62
60
  artifacts: z.ZodArray<z.ZodString>;
63
61
  payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
64
62
  fork_choice: z.ZodArray<z.ZodString> | z.ZodOptional<z.ZodArray<z.ZodString>>;
65
- clone_forks?: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>> | undefined;
66
63
  checklist_attestations: z.ZodOptional<z.ZodArray<z.ZodObject<{
67
64
  check_id: z.ZodString;
68
65
  items: z.ZodArray<z.ZodString>;
@@ -40,10 +40,8 @@ export const ASK_OPERATOR_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__ask_op
40
40
  * nothing else catches one drifting from the other.
41
41
  *
42
42
  * Field presence mirrors the typebox schema's own conditionality:
43
- * `clone_forks` only exists when a stage can clone-fork at all, and
44
43
  * `fork_choice` is required (not just present) when the stage has a fork
45
- * to route. Building this statically would silently offer the model a
46
- * `clone_forks` field on stages that have nowhere to route one.
44
+ * to route.
47
45
  */
48
46
  export function buildEmitStageEnvelopeShape(options) {
49
47
  return {
@@ -54,9 +52,6 @@ export function buildEmitStageEnvelopeShape(options) {
54
52
  fork_choice: options.forkEmitContext !== undefined
55
53
  ? z.array(z.string())
56
54
  : z.array(z.string()).optional(),
57
- ...(options.cloneEmitContext !== undefined
58
- ? { clone_forks: z.array(z.record(z.string(), z.unknown())) }
59
- : {}),
60
55
  checklist_attestations: z
61
56
  .array(z.object({
62
57
  check_id: z.string().min(1),
@@ -3,7 +3,6 @@ import path from "node:path";
3
3
  import { assertRequiredEnvelope, isAdvancingEnvelope, } from "../envelope/check.js";
4
4
  import { assertFeedbackLoopAction } from "../envelope/feedbackLoop.js";
5
5
  import { assertEnvelopePayload } from "../envelope/payloadSchema.js";
6
- import { assertCloneForks } from "../envelope/cloneForks.js";
7
6
  import { assertForkEnvelope } from "../envelope/forkChoice.js";
8
7
  import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, } from "../tools/askOperator.js";
9
8
  import { tryParsePendingPrompt } from "../runtime/stageHitl.js";
@@ -70,7 +69,9 @@ export class FakeAgent {
70
69
  let started = false;
71
70
  let resumeCorrupt = false;
72
71
  const sessionMode = input.sessionMode ?? "fresh";
73
- const skipHitlPark = sessionMode === "feedback_resume" || sessionMode === "new_session";
72
+ const skipHitlPark = sessionMode === "feedback_resume" ||
73
+ sessionMode === "timeout_resume" ||
74
+ sessionMode === "new_session";
74
75
  if (sessionMode === "feedback_resume" && input.feedbackLoopContext === undefined) {
75
76
  return createCompletedOnlyStageHandle({
76
77
  stageId: runtimeStageId(input),
@@ -154,15 +155,15 @@ export class FakeAgent {
154
155
  ? behavior.envelope
155
156
  : undefined;
156
157
  try {
157
- const envelope = assertRequiredEnvelope(envelopeValue);
158
+ let envelope = assertRequiredEnvelope(envelopeValue);
159
+ if (envelope.status === "success" && envelope.payload === undefined) {
160
+ envelope = { ...envelope, payload: {} };
161
+ }
158
162
  assertFeedbackLoopAction(envelope, input.feedbackLoopEmitContext);
159
163
  const isFeedbackSendBack = envelope.feedback_loop?.action === "send_back";
160
164
  if (!isFeedbackSendBack && input.forkEmitContext !== undefined) {
161
165
  assertForkEnvelope(envelope, input.forkEmitContext);
162
166
  }
163
- if (!isFeedbackSendBack && input.cloneEmitContext !== undefined) {
164
- assertCloneForks(envelope, input.cloneEmitContext);
165
- }
166
167
  assertEnvelopePayload(envelope, input.stage.payload_schema);
167
168
  if (!isAdvancingEnvelope(envelope)) {
168
169
  input.onActivity?.({ event: "agent_end" });
@@ -48,6 +48,7 @@ import { mapSessionEventToActivity, readActivityVerbose } from "./activity.js";
48
48
  import { createStageActivityObserver, } from "./activityObserver.js";
49
49
  import { DEFAULT_STAGE_TIMEOUT_MS, runtimeStageId, runStageViaOpen } from "./port.js";
50
50
  import { addModelUsage, emptyStageUsage } from "../types/usage.js";
51
+ import { TIMEOUT_ABORT_TOOL_RESULT, composeTimeoutResumePrompt, stageTimeoutReason, } from "./stageTimeout.js";
51
52
  /**
52
53
  * Stage tool allowlist for sealed Pi sessions.
53
54
  * Includes `ask_operator` unless `gateKinds` is an empty list (R6).
@@ -404,23 +405,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
404
405
  emitHint += `\nFailed-check evidence from attempt ${input.repairContext.prior_attempt}:\n${JSON.stringify(input.repairContext.failed_checks, null, 2)}`;
405
406
  }
406
407
  }
407
- if (input.cloneEmitContext !== undefined) {
408
- const allowedActions = input.cloneEmitContext.allowedActions ?? ["skip", "once", "fanout"];
409
- const successorLines = input.cloneEmitContext.clonableSuccessors.map((successor) => {
410
- const header = `- ${successor.successorId} (clone_cap: ${successor.cloneCap})`;
411
- if (successor.cloneInputSchema === undefined) {
412
- return header;
413
- }
414
- return `${header}\n Assignment schema (io.input.schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
415
- });
416
- emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
417
- emitHint += `\nEach once or fanout envelope is a full StageEnvelope — it requires status ("success" or "failure"), summary (non-empty string), and artifacts (array of strings). io.input.schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
418
- emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
419
- }
420
408
  if (input.feedbackLoopEmitContext !== undefined) {
421
409
  emitHint += `\nFeedback-loop decision: this stage is a feedback-loop source. On status=success, feedback_loop is required. Emit either {"action":"continue"} to proceed downstream or {"action":"send_back","target":"…"} to request another pass.`;
422
410
  emitHint += `\nAllowed send_back target: ${input.feedbackLoopEmitContext.target}.`;
423
- emitHint += `\nA send_back decision cannot be combined with fork_choice or clone_forks.`;
411
+ emitHint += `\nA send_back decision cannot be combined with fork_choice.`;
424
412
  }
425
413
  const attempt = input.roots.attempt ?? 1;
426
414
  const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
@@ -1041,6 +1029,7 @@ export class PiAgentAdapter {
1041
1029
  let sessionManager;
1042
1030
  let resumeWaiting = false;
1043
1031
  let feedbackResume = false;
1032
+ let timeoutResume = false;
1044
1033
  let forceNewSession = sessionMode === "new_session";
1045
1034
  if (sessionMode === "feedback_resume") {
1046
1035
  if (input.feedbackLoopContext === undefined) {
@@ -1062,6 +1051,23 @@ export class PiAgentAdapter {
1062
1051
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1063
1052
  feedbackResume = true;
1064
1053
  }
1054
+ else if (sessionMode === "timeout_resume") {
1055
+ if (!existsSync(sessionFile)) {
1056
+ throw new StageSessionReconstructError(`stage session file missing for timeout_resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1057
+ }
1058
+ try {
1059
+ sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), input.roots.cwd);
1060
+ }
1061
+ catch (err) {
1062
+ throw new StageSessionReconstructError(`stage session file corrupt or unreadable for timeout_resume: ${sessionFile}`, {
1063
+ stageId: runtimeStageId(input),
1064
+ sessionFile,
1065
+ cause: err,
1066
+ });
1067
+ }
1068
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1069
+ timeoutResume = true;
1070
+ }
1065
1071
  else if (sessionMode !== "new_session") {
1066
1072
  if (sessionMode === "waiting_resume" || existsSync(sessionFile)) {
1067
1073
  if (existsSync(sessionFile)) {
@@ -1128,13 +1134,17 @@ export class PiAgentAdapter {
1128
1134
  const workPromise = work();
1129
1135
  const abortPromise = new Promise((_, reject) => {
1130
1136
  controller.signal.addEventListener("abort", () => {
1131
- reject(new Error(`stage timed out after ${timeoutMs}ms`));
1137
+ void session?.abort();
1138
+ reject(new Error(stageTimeoutReason(timeoutMs)));
1132
1139
  });
1133
1140
  });
1134
1141
  await Promise.race([workPromise, abortPromise]);
1135
1142
  return { ...resultFromCapture(wiring.capture), usage: wiring.usage };
1136
1143
  }
1137
1144
  catch (err) {
1145
+ if (sessionManager) {
1146
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1147
+ }
1138
1148
  if (wiring.capture.envelope && isAdvancingEnvelope(wiring.capture.envelope)) {
1139
1149
  return { ok: true, envelope: wiring.capture.envelope, usage: wiring.usage };
1140
1150
  }
@@ -1164,6 +1174,15 @@ export class PiAgentAdapter {
1164
1174
  await session.prompt(composeFeedbackResumePrompt(input));
1165
1175
  return;
1166
1176
  }
1177
+ if (timeoutResume) {
1178
+ syncAgentMessagesFromSession(session, sessionManager);
1179
+ if (findOpenToolCall(sessionManager)) {
1180
+ injectOpaqueAnswerIntoSession(sessionManager, TIMEOUT_ABORT_TOOL_RESULT);
1181
+ syncAgentMessagesFromSession(session, sessionManager);
1182
+ }
1183
+ await session.prompt(composeTimeoutResumePrompt());
1184
+ return;
1185
+ }
1167
1186
  const userPrompt = composeStageUserPrompt(input, wiring.emitDefName, wiring.providerEmitHint, wiring.artifactDefName);
1168
1187
  await session.prompt(userPrompt);
1169
1188
  });
@@ -11,7 +11,7 @@ import type { QaExchange } from "../hitl/qaTrail.js";
11
11
  import type { StageUsage } from "../types/usage.js";
12
12
  /** Opaque to runtime; adapters interpret. */
13
13
  export type StageResumeToken = string;
14
- export type StageSessionMode = "fresh" | "waiting_resume" | "feedback_resume" | "new_session";
14
+ export type StageSessionMode = "fresh" | "waiting_resume" | "feedback_resume" | "timeout_resume" | "new_session";
15
15
  export type StageRepairContext = {
16
16
  prior_attempt: number;
17
17
  /** Operator-authored instructions for an explicitly approved manual retry. */
@@ -0,0 +1,9 @@
1
+ export declare const STAGE_TIMEOUT_REASON_PREFIX = "stage timed out after ";
2
+ export declare function stageTimeoutReason(timeoutMs: number): string;
3
+ export declare function isStageTimeoutReason(reason: string | undefined): boolean;
4
+ export declare function lastFailedReason(events: ReadonlyArray<{
5
+ event: string;
6
+ reason?: string;
7
+ }>): string | undefined;
8
+ export declare const TIMEOUT_ABORT_TOOL_RESULT = "Command aborted: the stage timed out. Continue without a debugger. Do not use --inspect or --inspect-brk.";
9
+ export declare function composeTimeoutResumePrompt(): string;
@@ -0,0 +1,24 @@
1
+ export const STAGE_TIMEOUT_REASON_PREFIX = "stage timed out after ";
2
+ export function stageTimeoutReason(timeoutMs) {
3
+ return `${STAGE_TIMEOUT_REASON_PREFIX}${timeoutMs}ms`;
4
+ }
5
+ export function isStageTimeoutReason(reason) {
6
+ return (typeof reason === "string" && reason.startsWith(STAGE_TIMEOUT_REASON_PREFIX));
7
+ }
8
+ export function lastFailedReason(events) {
9
+ let reason;
10
+ for (const event of events) {
11
+ if (event.event === "failed")
12
+ reason = event.reason;
13
+ }
14
+ return reason;
15
+ }
16
+ export const TIMEOUT_ABORT_TOOL_RESULT = "Command aborted: the stage timed out. Continue without a debugger. Do not use --inspect or --inspect-brk.";
17
+ export function composeTimeoutResumePrompt() {
18
+ return [
19
+ "The previous turn was interrupted because the stage timed out.",
20
+ "Continue the same work from the conversation above. Do not start over.",
21
+ "Do not attach a debugger (no --inspect or --inspect-brk).",
22
+ "Finish the stage and emit the envelope when done.",
23
+ ].join(" ");
24
+ }
@@ -0,0 +1,15 @@
1
+ import type { LoadedPipeline } from "../types/pipeline.js";
2
+ export declare const GRAPH_USAGE = "Usage:\n sf graph --pipeline <path> [--json]\n\n Prints a definition-time view of how a pipeline graph is wired, before any run.\n --pipeline loads and resolves the pipeline (same resolution as sf validate / sf run).\n --json dumps the resolved DAG as JSON instead of the terminal diagram.";
3
+ export type GraphCommandIo = {
4
+ log: (line: string) => void;
5
+ error: (line: string) => void;
6
+ };
7
+ export declare function runGraphCommand(args: string[], options?: {
8
+ cwd?: string;
9
+ projectRoot?: string;
10
+ io?: Partial<GraphCommandIo>;
11
+ loadPipeline?: (nameOrPath: string, opts: {
12
+ cwd?: string;
13
+ projectRoot?: string;
14
+ }) => Promise<LoadedPipeline>;
15
+ }): Promise<number>;
@@ -0,0 +1,79 @@
1
+ import { loadPipeline } from "../config/loadPipeline.js";
2
+ import { renderGraph } from "./graphRender.js";
3
+ export const GRAPH_USAGE = `Usage:
4
+ sf graph --pipeline <path> [--json]
5
+
6
+ Prints a definition-time view of how a pipeline graph is wired, before any run.
7
+ --pipeline loads and resolves the pipeline (same resolution as sf validate / sf run).
8
+ --json dumps the resolved DAG as JSON instead of the terminal diagram.`;
9
+ const defaultIo = {
10
+ log: (line) => console.log(line),
11
+ error: (line) => console.error(line),
12
+ };
13
+ function parseGraphArgs(args) {
14
+ if (args.length === 0) {
15
+ return { help: false, json: false };
16
+ }
17
+ if (args[0] === "--help" || args[0] === "-h") {
18
+ return { help: true, json: false };
19
+ }
20
+ let pipeline;
21
+ let json = false;
22
+ let help = false;
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (arg === "--help" || arg === "-h") {
26
+ help = true;
27
+ }
28
+ else if (arg === "--pipeline") {
29
+ const value = args[++i];
30
+ if (value === undefined || value.length === 0) {
31
+ throw new Error("Missing value for --pipeline");
32
+ }
33
+ pipeline = value;
34
+ }
35
+ else if (arg === "--json") {
36
+ json = true;
37
+ }
38
+ else if (arg.startsWith("-")) {
39
+ throw new Error(`Unknown flag: ${arg}`);
40
+ }
41
+ else {
42
+ throw new Error(`Unexpected argument: ${arg}`);
43
+ }
44
+ }
45
+ return { help, pipeline, json };
46
+ }
47
+ export async function runGraphCommand(args, options = {}) {
48
+ const cwd = options.cwd ?? process.cwd();
49
+ const projectRoot = options.projectRoot ?? cwd;
50
+ const out = { ...defaultIo, ...options.io };
51
+ const loadPipelineFn = options.loadPipeline ?? loadPipeline;
52
+ try {
53
+ const parsed = parseGraphArgs(args);
54
+ if (parsed.help) {
55
+ out.error(GRAPH_USAGE);
56
+ return 0;
57
+ }
58
+ if (parsed.pipeline === undefined) {
59
+ throw new Error("Missing value for --pipeline");
60
+ }
61
+ const loaded = await loadPipelineFn(parsed.pipeline, { cwd, projectRoot });
62
+ if (parsed.json) {
63
+ out.log(JSON.stringify(loaded.dag, null, 2));
64
+ return 0;
65
+ }
66
+ out.log(renderGraph(loaded.dag));
67
+ return 0;
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error
71
+ ? err.message
72
+ : typeof err === "string"
73
+ ? err
74
+ : "Graph command failed";
75
+ out.error(message);
76
+ out.error(GRAPH_USAGE);
77
+ return 1;
78
+ }
79
+ }
@@ -0,0 +1,2 @@
1
+ import type { ResolvedPipelineDag } from "../types/pipeline.js";
2
+ export declare function renderGraph(dag: ResolvedPipelineDag): string;
@@ -0,0 +1,85 @@
1
+ const MAX_WIDTH = 80;
2
+ // Clip to MAX_WIDTH code points so the picture fits an 80-column terminal.
3
+ function clampWidth(text, max = MAX_WIDTH) {
4
+ const chars = [...text];
5
+ if (chars.length <= max)
6
+ return text;
7
+ return chars.slice(0, max - 1).join("") + "…";
8
+ }
9
+ function cloneModeSuffix(node) {
10
+ return node.clone_mode ? ` (${node.clone_mode})` : "";
11
+ }
12
+ function buildAnnotations(node) {
13
+ const parts = [];
14
+ if (node.entry)
15
+ parts.push("entry");
16
+ if (node.clone_cap !== undefined) {
17
+ parts.push(`clone ${node.clone_cap}${cloneModeSuffix(node)}`);
18
+ }
19
+ if (node.replay_safe === false) {
20
+ parts.push("replay_safe: false");
21
+ }
22
+ return parts.join(" · ");
23
+ }
24
+ // loadPipeline returns a topologically ordered DAG, so a DF walk over childrenOf yields a stable, dependency-ordered list.
25
+ function orderedNodeIds(dag) {
26
+ const visited = new Set();
27
+ const order = [];
28
+ const walk = (id) => {
29
+ if (visited.has(id))
30
+ return;
31
+ visited.add(id);
32
+ order.push(id);
33
+ for (const child of dag.childrenOf[id] ?? [])
34
+ walk(child);
35
+ };
36
+ for (const root of dag.roots)
37
+ walk(root);
38
+ for (const node of dag.nodes) {
39
+ if (!visited.has(node.id))
40
+ walk(node.id);
41
+ }
42
+ return order;
43
+ }
44
+ function cloneBandLabel(node) {
45
+ if (node.clone_cap === undefined)
46
+ return "";
47
+ return ` child~${node.clone_cap}${cloneModeSuffix(node)}`;
48
+ }
49
+ function loopCueLine(node) {
50
+ if (!node.feedback_loop)
51
+ return "";
52
+ const fl = node.feedback_loop;
53
+ const note = fl.on_max_replays ? ` (${fl.on_max_replays})` : "";
54
+ return ` ↩ loop ×${fl.max_replays} → ${fl.target}${note}`;
55
+ }
56
+ export function renderGraph(dag) {
57
+ const byId = new Map(dag.nodes.map((node) => [node.id, node]));
58
+ const order = orderedNodeIds(dag);
59
+ const lines = [];
60
+ const push = (line) => lines.push(clampWidth(line));
61
+ order.forEach((id, idx) => {
62
+ const node = byId.get(id);
63
+ const marker = node.entry ? "▶ " : " ";
64
+ const annotations = buildAnnotations(node);
65
+ push(`${marker}${id}` + (annotations ? ` ${annotations}` : ""));
66
+ const loopCue = loopCueLine(node);
67
+ if (loopCue)
68
+ push(loopCue);
69
+ if (idx < order.length - 1) {
70
+ const children = dag.childrenOf[id] ?? [];
71
+ if (children.length === 1) {
72
+ push(" │");
73
+ push(` ▼${cloneBandLabel(node)}`);
74
+ }
75
+ else if (children.length > 1) {
76
+ children.forEach((childId, ci) => {
77
+ const isLast = ci === children.length - 1;
78
+ const branch = isLast ? "└─" : "├─";
79
+ push(` ${branch}▶ ${childId}${cloneBandLabel(node)}`);
80
+ });
81
+ }
82
+ }
83
+ });
84
+ return lines.join("\n") + "\n";
85
+ }
@@ -1,6 +1,6 @@
1
1
  import type { RunStore } from "../runstore/port.js";
2
2
  import { RunManager } from "../runtime/runManager.js";
3
- export declare const RUNS_USAGE = "Usage:\n sf runs list [--status created|running|succeeded|failed] [--since <iso>] [--pipeline <id-or-path>] [--json]\n sf runs show --run <runId> [--from <sf-run.json>] [--json]\n sf runs verify --run <runId> --stage <stageId> [--json]\n sf runs recover --run <runId> --stage <stageId> [--guidance <text>] [--stop] [--json]\n sf runs waiting [--run <runId>] [--json]\n sf runs wait --run <runId> [--from <sf-run.json>] [--until any|waiting|terminal] [--timeout-ms <n>] [--json]\n sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]\n sf runs feedback-decide --run <runId> --stage <sourceStageId> [--loop <loopId>] --decision extend|continue|abandon [--reason <text>] [--json]\n sf runs retry --run <runId> --stage <stageId> [--json]\n sf runs abandon --run <runId> --stage <stageId> [--json]\n sf runs rerun --run <runId> [--json]";
3
+ export declare const RUNS_USAGE = "Usage:\n sf runs list [--status created|running|succeeded|failed] [--since <iso>] [--pipeline <id-or-path>] [--json]\n sf runs show --run <runId> [--from <sf-run.json>] [--json]\n sf runs verify --run <runId> --stage <stageId> [--json]\n sf runs recover --run <runId> --stage <stageId> [--guidance <text>] [--stop] [--json]\n sf runs waiting [--run <runId>] [--json]\n sf runs wait --run <runId> [--from <sf-run.json>] [--until any|waiting|terminal] [--timeout-ms <n>] [--json]\n sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]\n sf runs feedback-decide --run <runId> --stage <sourceStageId> [--loop <loopId>] --decision extend|continue|abandon [--reason <text>] [--json]\n sf runs retry --run <runId> --stage <stageId> [--json]\n sf runs resume --run <runId> --stage <stageId> [--json]\n sf runs abandon --run <runId> --stage <stageId> [--json]\n sf runs rerun --run <runId> [--json]";
4
4
  export type RunsCommandIo = {
5
5
  log: (line: string) => void;
6
6
  error: (line: string) => void;
@@ -24,6 +24,7 @@ export const RUNS_USAGE = `Usage:
24
24
  sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]
25
25
  sf runs feedback-decide --run <runId> --stage <sourceStageId> [--loop <loopId>] --decision extend|continue|abandon [--reason <text>] [--json]
26
26
  sf runs retry --run <runId> --stage <stageId> [--json]
27
+ sf runs resume --run <runId> --stage <stageId> [--json]
27
28
  sf runs abandon --run <runId> --stage <stageId> [--json]
28
29
  sf runs rerun --run <runId> [--json]`;
29
30
  const defaultIo = {
@@ -122,6 +123,8 @@ function flagsFor(subcommand) {
122
123
  return FEEDBACK_DECIDE_FLAGS;
123
124
  case "retry":
124
125
  return RETRY_FLAGS;
126
+ case "resume":
127
+ return RETRY_FLAGS;
125
128
  case "abandon":
126
129
  return ABANDON_FLAGS;
127
130
  case "rerun":
@@ -675,6 +678,40 @@ export async function runRunsCommand(args, options = {}) {
675
678
  }
676
679
  return reportCliRun({ kind: "completion", result: result.pipeline }, { json: parsed.json, io: mutatingIo });
677
680
  }
681
+ case "resume": {
682
+ const blocked = await guardHost();
683
+ if (blocked !== undefined)
684
+ return blocked;
685
+ if (!parsed.runId || !parsed.stageId) {
686
+ return usageError(out, "Missing --run and/or --stage");
687
+ }
688
+ const manager = buildManager();
689
+ const result = await manager.resumeTimedOutStage(parsed.runId, parsed.stageId);
690
+ if (!result.ok) {
691
+ if (parsed.json) {
692
+ printJson(out, {
693
+ ...mapRetryStageFailure(result),
694
+ status: result.status,
695
+ });
696
+ }
697
+ else {
698
+ out.error(result.reason);
699
+ }
700
+ return 1;
701
+ }
702
+ if (parsed.json) {
703
+ printJson(out, {
704
+ ok: true,
705
+ runId: result.runId,
706
+ stageId: result.stageId,
707
+ attemptIndex: result.attemptIndex,
708
+ });
709
+ }
710
+ else {
711
+ out.log(`ok\t${result.runId}\t${result.stageId}\t${result.attemptIndex}`);
712
+ }
713
+ return 0;
714
+ }
678
715
  case "recover": {
679
716
  const blocked = await guardHost();
680
717
  if (blocked !== undefined)
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { RUNS_USAGE, runRunsCommand } from "./cli/runsCommand.js";
14
14
  import { resolveOperatorCatalog } from "./cli/operatorCatalog.js";
15
15
  import { SKILLS_USAGE, runSkillsCommand } from "./cli/skillsCommand.js";
16
16
  import { VALIDATE_USAGE, runValidateCommand } from "./cli/validateCommand.js";
17
+ import { GRAPH_USAGE, runGraphCommand } from "./cli/graphCommand.js";
17
18
  import { MIGRATE_YAML_USAGE, runMigrateYamlCommand } from "./cli/migrateYamlCommand.js";
18
19
  import { createRunStore } from "./runstore/createStore.js";
19
20
  import { resolveStageflowContext } from "./project/resolveStageflowContext.js";
@@ -27,6 +28,7 @@ const USAGE = `Usage:
27
28
  sf init
28
29
  sf run --task <path> --pipeline <path> [--checkout <path>] [--json] [--include stages] [--skip-gates] [--git-sha <sha>] [--ci-pr-url <url>] [--ci-job-url <url>] [--operator-cwd <path>] [--operator-agent-dir <path>]
29
30
  sf validate [--pipeline <path>] [--task <path>] [--strict] [--json]
31
+ sf graph --pipeline <path> [--json]
30
32
  sf migrate-yaml [path] [--root <path>] [--write] [--json] [--force]
31
33
  sf artifact read --run <runId> --path <relPath> [--out <file>]
32
34
  sf envelope get --run <runId> --stage <stageId> [--json] [--from <sf-run.json>] [--detect-stage <id>] [--format envelope|handoff]
@@ -39,6 +41,7 @@ const USAGE = `Usage:
39
41
  sf runs wait --run <runId> [--from <sf-run.json>] [--until any|waiting|terminal] [--timeout-ms <n>] [--json]
40
42
  sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]
41
43
  sf runs retry --run <runId> --stage <stageId> [--json]
44
+ sf runs resume --run <runId> --stage <stageId> [--json]
42
45
  sf runs abandon --run <runId> --stage <stageId> [--json]
43
46
  sf runs rerun --run <runId> [--json]
44
47
  sf ui [--port ${DEFAULT_PORT}] [--mcp-stateless]
@@ -66,6 +69,8 @@ ${RUN_USAGE}
66
69
 
67
70
  ${VALIDATE_USAGE}
68
71
 
72
+ ${GRAPH_USAGE}
73
+
69
74
  ${MIGRATE_YAML_USAGE}
70
75
 
71
76
  ${ARTIFACT_USAGE}
@@ -97,6 +102,7 @@ function parseArgs(argv) {
97
102
  const command = args[0];
98
103
  if (command === "providers" ||
99
104
  command === "validate" ||
105
+ command === "graph" ||
100
106
  command === "migrate-yaml" ||
101
107
  command === "run" ||
102
108
  command === "init" ||
@@ -285,6 +291,12 @@ async function main(argv) {
285
291
  projectRoot: ctx.projectRoot,
286
292
  });
287
293
  }
294
+ if (parsed.command === "graph") {
295
+ return runGraphCommand(argv.slice(3), {
296
+ cwd: ctx.invocationCwd,
297
+ projectRoot: ctx.projectRoot,
298
+ });
299
+ }
288
300
  if (parsed.command === "migrate-yaml") {
289
301
  return runMigrateYamlCommand(argv.slice(3), {
290
302
  cwd: ctx.invocationCwd,
@@ -0,0 +1,12 @@
1
+ import type { PipelineStageRef, ResolvedPipelineDag } from "../types/pipeline.js";
2
+ import type { StageConfig } from "../types/stage.js";
3
+ import { type LoadOutcome } from "./loadOutcome.js";
4
+ export type DetectedCloneChain = {
5
+ emitterId: string;
6
+ cloneChildId: string;
7
+ joinId: string;
8
+ arrayField: string;
9
+ ref: string;
10
+ };
11
+ export declare function detectCloneChains(stages: StageConfig[], refs: PipelineStageRef[], pipelineId: string): LoadOutcome<DetectedCloneChain[]>;
12
+ export declare function applyCloneChains(stages: StageConfig[], refs: PipelineStageRef[], dag: ResolvedPipelineDag, pipelineId: string): LoadOutcome<void>;