stageflow 0.18.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 (41) hide show
  1. package/dist/agent/activity.d.ts +4 -0
  2. package/dist/agent/claudeAdapter.js +38 -0
  3. package/dist/agent/fakeAgent.js +3 -1
  4. package/dist/agent/piAdapter.js +33 -1
  5. package/dist/agent/port.d.ts +1 -1
  6. package/dist/agent/stageTimeout.d.ts +9 -0
  7. package/dist/agent/stageTimeout.js +24 -0
  8. package/dist/cli/graphCommand.d.ts +15 -0
  9. package/dist/cli/graphCommand.js +79 -0
  10. package/dist/cli/graphRender.d.ts +2 -0
  11. package/dist/cli/graphRender.js +85 -0
  12. package/dist/cli/runsCommand.d.ts +1 -1
  13. package/dist/cli/runsCommand.js +37 -0
  14. package/dist/cli.js +12 -0
  15. package/dist/mcp/controlTools.js +17 -0
  16. package/dist/package-meta.d.ts +1 -1
  17. package/dist/package-meta.js +1 -1
  18. package/dist/runstore/port.js +2 -0
  19. package/dist/runtime/forkGeneration.d.ts +4 -0
  20. package/dist/runtime/forkGeneration.js +16 -0
  21. package/dist/runtime/pipelineScheduler.js +23 -1
  22. package/dist/runtime/reopenSkipped.d.ts +13 -0
  23. package/dist/runtime/reopenSkipped.js +56 -0
  24. package/dist/runtime/resumeTimedOut.d.ts +33 -0
  25. package/dist/runtime/resumeTimedOut.js +165 -0
  26. package/dist/runtime/runManager.d.ts +1 -0
  27. package/dist/runtime/runManager.js +76 -0
  28. package/dist/runtime/stageRecovery.js +2 -2
  29. package/dist/runtime/stageRunner.js +6 -2
  30. package/dist/server/http.js +18 -0
  31. package/dist/tools/askOperator.d.ts +1 -0
  32. package/dist/tools/askOperator.js +14 -11
  33. package/dist/tools/emitStageEnvelope.d.ts +1 -0
  34. package/dist/tools/emitStageEnvelope.js +3 -1
  35. package/dist/tools/prepareToolArguments.d.ts +4 -0
  36. package/dist/tools/prepareToolArguments.js +79 -0
  37. package/dist/ui/assets/{index-DsgVjLUS.css → index-CSsDued1.css} +1 -1
  38. package/dist/ui/assets/index-DMVdl1qB.js +119 -0
  39. package/dist/ui/index.html +2 -2
  40. package/package.json +1 -1
  41. package/dist/ui/assets/index-DwKGaQvQ.js +0 -119
@@ -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
  },
@@ -69,7 +69,9 @@ export class FakeAgent {
69
69
  let started = false;
70
70
  let resumeCorrupt = false;
71
71
  const sessionMode = input.sessionMode ?? "fresh";
72
- const skipHitlPark = sessionMode === "feedback_resume" || sessionMode === "new_session";
72
+ const skipHitlPark = sessionMode === "feedback_resume" ||
73
+ sessionMode === "timeout_resume" ||
74
+ sessionMode === "new_session";
73
75
  if (sessionMode === "feedback_resume" && input.feedbackLoopContext === undefined) {
74
76
  return createCompletedOnlyStageHandle({
75
77
  stageId: runtimeStageId(input),
@@ -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).
@@ -1028,6 +1029,7 @@ export class PiAgentAdapter {
1028
1029
  let sessionManager;
1029
1030
  let resumeWaiting = false;
1030
1031
  let feedbackResume = false;
1032
+ let timeoutResume = false;
1031
1033
  let forceNewSession = sessionMode === "new_session";
1032
1034
  if (sessionMode === "feedback_resume") {
1033
1035
  if (input.feedbackLoopContext === undefined) {
@@ -1049,6 +1051,23 @@ export class PiAgentAdapter {
1049
1051
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1050
1052
  feedbackResume = true;
1051
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
+ }
1052
1071
  else if (sessionMode !== "new_session") {
1053
1072
  if (sessionMode === "waiting_resume" || existsSync(sessionFile)) {
1054
1073
  if (existsSync(sessionFile)) {
@@ -1115,13 +1134,17 @@ export class PiAgentAdapter {
1115
1134
  const workPromise = work();
1116
1135
  const abortPromise = new Promise((_, reject) => {
1117
1136
  controller.signal.addEventListener("abort", () => {
1118
- reject(new Error(`stage timed out after ${timeoutMs}ms`));
1137
+ void session?.abort();
1138
+ reject(new Error(stageTimeoutReason(timeoutMs)));
1119
1139
  });
1120
1140
  });
1121
1141
  await Promise.race([workPromise, abortPromise]);
1122
1142
  return { ...resultFromCapture(wiring.capture), usage: wiring.usage };
1123
1143
  }
1124
1144
  catch (err) {
1145
+ if (sessionManager) {
1146
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1147
+ }
1125
1148
  if (wiring.capture.envelope && isAdvancingEnvelope(wiring.capture.envelope)) {
1126
1149
  return { ok: true, envelope: wiring.capture.envelope, usage: wiring.usage };
1127
1150
  }
@@ -1151,6 +1174,15 @@ export class PiAgentAdapter {
1151
1174
  await session.prompt(composeFeedbackResumePrompt(input));
1152
1175
  return;
1153
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
+ }
1154
1186
  const userPrompt = composeStageUserPrompt(input, wiring.emitDefName, wiring.providerEmitHint, wiring.artifactDefName);
1155
1187
  await session.prompt(userPrompt);
1156
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,
@@ -183,6 +183,23 @@ export function registerControlTools(server, deps) {
183
183
  attemptIndex: result.attemptIndex,
184
184
  });
185
185
  });
186
+ server.registerTool("resume_stage", {
187
+ description: "Resume a stage that failed because it timed out, continuing the same attempt/session (same as POST .../resume). Does not start a new attempt — use retry_stage to start over.",
188
+ inputSchema: z.object({
189
+ runId: z.string(),
190
+ stageId: z.string(),
191
+ }),
192
+ }, async ({ runId, stageId }) => {
193
+ const result = await manager.resumeTimedOutStage(runId, stageId);
194
+ if (!result.ok) {
195
+ return textResult({ ...mapRetryStageFailure(result), status: result.status }, true);
196
+ }
197
+ return textResult({
198
+ runId: result.runId,
199
+ stageId: result.stageId,
200
+ attemptIndex: result.attemptIndex,
201
+ });
202
+ });
186
203
  server.registerTool("abandon_stage", {
187
204
  description: "Abandon a running stage (marks it failed/interrupted). Does not dismiss HITL — waiting stages return 409; answer them with answer_gate. There is no run-level cancel tool.",
188
205
  inputSchema: z.object({
@@ -1,2 +1,2 @@
1
1
  export declare const PACKAGE_NAME = "stageflow";
2
- export declare const PACKAGE_VERSION = "0.18.0";
2
+ export declare const PACKAGE_VERSION = "0.19.0";
@@ -1,2 +1,2 @@
1
1
  export const PACKAGE_NAME = "stageflow";
2
- export const PACKAGE_VERSION = "0.18.0";
2
+ export const PACKAGE_VERSION = "0.19.0";
@@ -12,6 +12,8 @@ export function stageStatusFromEvents(events) {
12
12
  status = "failed";
13
13
  if (ev.event === "skipped")
14
14
  status = "skipped";
15
+ if (ev.event === "reopened")
16
+ status = "pending";
15
17
  }
16
18
  return status;
17
19
  }
@@ -63,6 +63,10 @@ export declare function selectActiveInput(options: {
63
63
  needsStageId: string;
64
64
  scheduleOverride?: ReadonlyMap<string, ReadonlySet<string>>;
65
65
  }): Promise<ActiveCohort>;
66
+ export declare function forkCohortMapsFromStore(store: Pick<RunStore, "listForkGenerations">, runId: string): Promise<{
67
+ supersededCloneIds: Set<string>;
68
+ cohortByForkParent: Map<string, Set<string>>;
69
+ }>;
66
70
  export declare function filterJoinInputs(allInstanceIds: readonly string[], cohort: ActiveCohort): string[];
67
71
  export declare function filterJoinInstanceIds(allInstanceIds: readonly string[], activeCloneIds: Set<string> | null): string[];
68
72
  /**
@@ -251,6 +251,22 @@ export async function selectActiveInput(options) {
251
251
  }
252
252
  return activeCohortForParent(options.store, options.runId, forkParentId);
253
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
+ }
254
270
  export function filterJoinInputs(allInstanceIds, cohort) {
255
271
  if (cohort.kind === "untracked")
256
272
  return [...allInstanceIds];
@@ -4,7 +4,8 @@ import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
4
4
  import { cloneFailureContinuesSchedule, cloneRetryDownstream, cloneFailFastSkipIds, cloneScheduleAllowsRun, failureIsAccepted, isCloneInstance, mint, protectedClonableChildIds, sequentialLaterCloneIds, skipRejectedNeedDependents, } from "./cloneSchedule.js";
5
5
  import { buildCompletedEnvelopesFromRun, buildStageConfigById, } from "./envelopeRouting.js";
6
6
  import { classifyInboundAfterSuccess, isEagerSingleParentIfSkip, joinAllowsRun, pickStalledJoinSkips, } from "./joinReadiness.js";
7
- import { forkChoicePreservesReplaySource, mintCohortForFanout, } from "./forkGeneration.js";
7
+ import { reopenRunnableSkippedStages, persistReopenedStages } from "./reopenSkipped.js";
8
+ import { forkChoicePreservesReplaySource, forkCohortMapsFromStore, mintCohortForFanout, } from "./forkGeneration.js";
8
9
  import { activeReplayId, activeSourceStageId, applySendBack, createReplaySchedule, hydrateFromStore, isHeld, launchAttemptFor, launchFor, noteActiveCohort, onContinued, onRouteStageFailed, onRouteStageRunning, onRouteStageSucceeded, rebindRouteAttempts, resolveDecision, } from "./replayLifecycle.js";
9
10
  import { WAIT_WITHOUT_WORKER_DISPATCH } from "./answerResume.js";
10
11
  import { runStage, isRunStageWaiting } from "./stageRunner.js";
@@ -65,6 +66,7 @@ export async function hydrateScheduleFromStore(store, runId, dag, executionMode
65
66
  continue;
66
67
  schedulingHalted = true;
67
68
  }
69
+ await reopenRunnableSkippedStages(store, runId, resolvedDag, states, completedEnvelopes);
68
70
  return {
69
71
  states,
70
72
  completedEnvelopes,
@@ -85,15 +87,20 @@ export async function hydrateScheduleForRetryRoots(store, runId, dag, retryStage
85
87
  }
86
88
  }
87
89
  const retryRootSet = new Set(retryStageIds);
90
+ const { supersededCloneIds } = await forkCohortMapsFromStore(store, runId);
88
91
  const states = new Map();
89
92
  for (const node of resolvedDag.nodes) {
90
93
  states.set(node.id, "pending");
91
94
  }
92
95
  const overrides = new Map();
93
96
  for (const rootId of retryStageIds) {
97
+ if (supersededCloneIds.has(rootId))
98
+ continue;
94
99
  overrides.set(rootId, "pending");
95
100
  }
96
101
  for (const id of downstream) {
102
+ if (supersededCloneIds.has(id))
103
+ continue;
97
104
  overrides.set(id, "pending");
98
105
  }
99
106
  for (const snap of runDetail.stages) {
@@ -104,6 +111,20 @@ export async function hydrateScheduleForRetryRoots(store, runId, dag, retryStage
104
111
  completedEnvelopes.delete(id);
105
112
  }
106
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);
107
128
  return {
108
129
  states,
109
130
  completedEnvelopes,
@@ -752,6 +773,7 @@ export async function runPipelineDag(options) {
752
773
  await persistSkipPending(childId);
753
774
  }
754
775
  }
776
+ await reopenRunnableSkippedStages(store, run.runId, dag, states, completedEnvelopes);
755
777
  };
756
778
  const handlePostSuccessError = async (stageId, reason) => {
757
779
  if (states.get(stageId) === "succeeded") {