@wichayutdew/pi-workflows 2.1.0 → 2.3.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.
package/dist/index.js CHANGED
@@ -133,6 +133,7 @@ var DEFAULT_SETTINGS = {
133
133
  var PROMPT_VARIABLES = new Set([
134
134
  "workflow.input",
135
135
  "workflow.id",
136
+ "workflow.iteration",
136
137
  "run.id",
137
138
  "step.id",
138
139
  "step.title",
@@ -141,7 +142,8 @@ var PROMPT_VARIABLES = new Set([
141
142
  "reviewed.feedback",
142
143
  "gate.artifact",
143
144
  "gate.feedback",
144
- "resume.input"
145
+ "resume.input",
146
+ "restart.workspace"
145
147
  ]);
146
148
  function validatePromptText(text, path) {
147
149
  return [...text.matchAll(/\{\{([^{}]+)\}\}/g)].flatMap((match) => {
@@ -661,6 +663,7 @@ var HARNESS_COMMAND_NAMES = [
661
663
  "workflow-list",
662
664
  "workflow-pause",
663
665
  "workflow-reload",
666
+ "workflow-restart",
664
667
  "workflow-resume",
665
668
  "workflow-start"
666
669
  ];
@@ -1343,6 +1346,13 @@ function createHarnessCommands(controller) {
1343
1346
  }
1344
1347
  },
1345
1348
  createStartCommand(controller),
1349
+ {
1350
+ name: "workflow-restart",
1351
+ options: {
1352
+ description: "Restart the completed workflow in its worktree: /workflow-restart [input]",
1353
+ handler: async (input, context) => controller.restart(input.trim(), context)
1354
+ }
1355
+ },
1346
1356
  {
1347
1357
  name: "workflow-pause",
1348
1358
  options: {
@@ -2947,7 +2957,7 @@ function parseAvailableSkills(systemPrompt) {
2947
2957
 
2948
2958
  // src/harness/dependencies.ts
2949
2959
  import { randomBytes, randomUUID } from "node:crypto";
2950
- import { constants as constants3, mkdtempSync, writeFileSync } from "node:fs";
2960
+ import { constants as constants3, mkdtempSync, writeFileSync as writeFileSync2 } from "node:fs";
2951
2961
  import { lstat as lstat3, open as open3, rm } from "node:fs/promises";
2952
2962
  import { tmpdir as tmpdir2 } from "node:os";
2953
2963
  import { join as join5 } from "node:path";
@@ -4210,6 +4220,7 @@ function renderSummaryLines(theme, snapshot, width) {
4210
4220
  ...keyValueLines(theme, "about", workflow.definition.description, width)
4211
4221
  ] : [],
4212
4222
  ...keyValueLines(theme, "run", run.runId, width),
4223
+ ...run.iteration && run.iteration > 1 ? [...keyValueLines(theme, "iteration", String(run.iteration), width)] : [],
4213
4224
  ...keyValueLines(theme, "status", statusLabel(run.status), width, statusColor(run.status)),
4214
4225
  ...keyValueLines(theme, "current", formatStepName(stepTitle(workflow, run.currentStepId), run.currentStepId), width),
4215
4226
  ...keyValueLines(theme, "visit", String(Math.max(1, run.visits[run.currentStepId] ?? 1)), width),
@@ -4474,10 +4485,11 @@ import { lstat as lstat2, open as open2, realpath as realpath3 } from "node:fs/p
4474
4485
  import { isAbsolute as isAbsolute9, relative as relative5, resolve as resolve9, sep as sep4 } from "node:path";
4475
4486
 
4476
4487
  // src/engine/create-run.ts
4477
- var createRun = (workflow, input, baselineTools, runId, now, cwd) => {
4488
+ var createRun = (workflow, input, baselineTools, runId, now, cwd, iteration = 1) => {
4478
4489
  const startStepId = workflow.definition.start;
4479
4490
  return {
4480
4491
  stateVersion: RUN_STATE_VERSION,
4492
+ iteration,
4481
4493
  runId,
4482
4494
  workflowId: workflow.definition.id,
4483
4495
  workflowDigest: workflow.digest,
@@ -4870,6 +4882,7 @@ var isPendingGate = (value) => isRecord8(value) && (value.provider === "prompt"
4870
4882
  var isVisitCounts = (value) => isRecord8(value) && Object.values(value).every((count) => typeof count === "number" && Number.isInteger(count) && count >= 0);
4871
4883
  var isOptionalString = (value) => value === undefined || typeof value === "string";
4872
4884
  var isOptionalResumeInput = (value) => value === undefined || typeof value === "string" && value.length <= MAX_RESUME_INPUT_CHARS;
4885
+ var isOptionalIteration = (value) => value === undefined || Number.isSafeInteger(value) && value >= 1;
4873
4886
  var isWorkflowRunStatus = (value) => value === "running" || value === "paused" || value === "awaiting-gate" || value === "completed" || value === "aborted";
4874
4887
  var hasValidPauseState = (run) => run.status === "paused" ? run.pausedFrom === "running" || run.pausedFrom === "awaiting-gate" : run.pausedFrom === undefined;
4875
4888
  var hasValidFailureState = (run) => run.failedStepId === undefined || run.status === "paused" && run.failedStepId === run.currentStepId;
@@ -4896,12 +4909,15 @@ var isWorkflowRun = (value) => {
4896
4909
  const hasValidRequiredFields = value.stateVersion === RUN_STATE_VERSION && typeof value.runId === "string" && typeof value.workflowId === "string" && typeof value.workflowDigest === "string" && typeof value.input === "string" && isWorkflowRunStatus(value.status) && typeof value.currentStepId === "string" && typeof value.currentStepDigest === "string" && Array.isArray(value.baselineTools) && value.baselineTools.every((tool) => typeof tool === "string") && Array.isArray(value.history) && value.history.every(isStepHistoryEntry) && (value.currentStepAttempts === undefined || isStepExecutionAttempts(value.currentStepAttempts)) && (value.currentStepOmittedAttempts === undefined || Number.isSafeInteger(value.currentStepOmittedAttempts) && value.currentStepOmittedAttempts > 0) && isVisitCounts(value.visits) && typeof value.startedAt === "number" && typeof value.updatedAt === "number" && typeof value.lastSummary === "string" && typeof value.gateFeedback === "string" && value.gateFeedback.length <= MAX_GATE_FEEDBACK_CHARS;
4897
4910
  if (!hasValidRequiredFields)
4898
4911
  return false;
4899
- const hasValidOptionalFields = isOptionalString(value.reviewedArtifact) && isOptionalString(value.reviewedFeedback) && (typeof value.reviewedFeedback !== "string" || value.reviewedFeedback.length <= MAX_GATE_FEEDBACK_CHARS) && isOptionalString(value.stepHandoff) && isOptionalString(value.gateArtifact) && isOptionalResumeInput(value.resumeInput) && isOptionalString(value.pauseReason) && isOptionalString(value.failedStepId) && (value.pausedFrom === undefined || value.pausedFrom === "running" || value.pausedFrom === "awaiting-gate");
4912
+ const hasValidOptionalFields = isOptionalIteration(value.iteration) && isOptionalString(value.reviewedArtifact) && isOptionalString(value.reviewedFeedback) && (typeof value.reviewedFeedback !== "string" || value.reviewedFeedback.length <= MAX_GATE_FEEDBACK_CHARS) && isOptionalString(value.stepHandoff) && isOptionalString(value.gateArtifact) && (value.restartWorkspaceCwd === undefined || isAbsoluteCwd(value.restartWorkspaceCwd)) && isOptionalResumeInput(value.resumeInput) && isOptionalString(value.pauseReason) && isOptionalString(value.failedStepId) && (value.pausedFrom === undefined || value.pausedFrom === "running" || value.pausedFrom === "awaiting-gate");
4900
4913
  if (!hasValidOptionalFields)
4901
4914
  return false;
4902
4915
  const pendingGate = value.pendingGate;
4903
4916
  if (pendingGate !== undefined && !isPendingGate(pendingGate))
4904
4917
  return false;
4918
+ if (value.restartWorkspaceCwd !== undefined && value.history.some((entry) => entry.workspaceCwd !== undefined)) {
4919
+ return false;
4920
+ }
4905
4921
  return workflowTraceChars(value) <= MAX_WORKFLOW_TRACE_CHARS && hasValidWorkspaceState(value, value.history) && hasValidPauseState(value) && hasValidFailureState(value) && hasValidGateState(value, pendingGate);
4906
4922
  };
4907
4923
  // src/workflow-status/transcript-reader.ts
@@ -5336,6 +5352,42 @@ function resolveWorkspaceDirectory({
5336
5352
  return canonicalCwd;
5337
5353
  }
5338
5354
 
5355
+ // src/harness/session-persistence.ts
5356
+ import { existsSync, writeFileSync } from "node:fs";
5357
+ var isAlreadyPersisted = (error) => error instanceof Error && ("code" in error) && error.code === "EEXIST";
5358
+ function getAdoptableSession(session) {
5359
+ const adoptable = session;
5360
+ if (typeof adoptable.setSessionFile !== "function") {
5361
+ throw new Error("This Pi runtime cannot adopt a materialized workflow session file");
5362
+ }
5363
+ return adoptable;
5364
+ }
5365
+ function flushUnwrittenSession(session) {
5366
+ const sessionFile = session.getSessionFile();
5367
+ const header = session.getHeader();
5368
+ if (!sessionFile || !header)
5369
+ return false;
5370
+ if (existsSync(sessionFile))
5371
+ return false;
5372
+ const adoptable = getAdoptableSession(session);
5373
+ const serialized = [header, ...session.getEntries()].map((entry) => JSON.stringify(entry)).join(`
5374
+ `);
5375
+ try {
5376
+ writeFileSync(sessionFile, `${serialized}
5377
+ `, {
5378
+ encoding: "utf8",
5379
+ flag: "wx",
5380
+ mode: 384
5381
+ });
5382
+ adoptable.setSessionFile(sessionFile);
5383
+ return true;
5384
+ } catch (error) {
5385
+ if (isAlreadyPersisted(error))
5386
+ return false;
5387
+ throw error;
5388
+ }
5389
+ }
5390
+
5339
5391
  // src/harness/dependencies.ts
5340
5392
  var MAX_DELEGATED_RESULT_BYTES = 1024 * 1024;
5341
5393
  function createDelegationWorkspace() {
@@ -5343,7 +5395,7 @@ function createDelegationWorkspace() {
5343
5395
  const capabilityPath = join5(resultDirectory, "capability");
5344
5396
  const capabilityToken = randomBytes(32).toString("hex");
5345
5397
  const resultPath = join5(resultDirectory, "result.json");
5346
- writeFileSync(capabilityPath, capabilityToken, {
5398
+ writeFileSync2(capabilityPath, capabilityToken, {
5347
5399
  encoding: "utf8",
5348
5400
  flag: "wx",
5349
5401
  mode: 384
@@ -5400,6 +5452,7 @@ var DEFAULT_DEPENDENCIES3 = {
5400
5452
  createSubagentClient: (pi) => createSubagentDelegationClient(pi.events),
5401
5453
  createMainStepRuntime: (pi) => createMainStepRuntime({ pi }),
5402
5454
  createMutationQueue: createSerialTaskQueue,
5455
+ flushUnwrittenSession,
5403
5456
  scheduleInterval: (operation, intervalMs) => setInterval(operation, intervalMs),
5404
5457
  cancelInterval: (timer) => {
5405
5458
  clearInterval(timer);
@@ -5516,831 +5569,983 @@ function createStatusActions() {
5516
5569
  };
5517
5570
  }
5518
5571
 
5519
- // src/workflow-doctor.ts
5520
- var lexical = (left, right) => left < right ? -1 : left > right ? 1 : 0;
5521
- var internalTargets = (step) => [
5522
- ...new Set(Object.values(step.transitions).filter((target) => target !== "$done" && target !== "$pause"))
5523
- ].sort(lexical);
5524
- var adjacencyFor = (definition) => Object.fromEntries(Object.entries(definition.steps).sort(([left], [right]) => lexical(left, right)).map(([stepId, step]) => [stepId, internalTargets(step)]));
5525
- var reachableSteps = (definition, adjacency) => {
5526
- const reachable = new Set;
5527
- const pending = [definition.start];
5528
- while (pending.length > 0) {
5529
- const stepId = pending.pop();
5530
- if (!stepId || reachable.has(stepId))
5531
- continue;
5532
- if (!definition.steps[stepId])
5533
- continue;
5534
- reachable.add(stepId);
5535
- pending.push(...adjacency[stepId] ?? []);
5572
+ // src/engine/transition-helpers.ts
5573
+ var currentStep = (workflow, run) => workflow.definition.steps[run.currentStepId];
5574
+ var withRunUpdate = (run, changes, now) => ({ ...run, ...changes, updatedAt: now });
5575
+
5576
+ // src/engine/run-advance.ts
5577
+ var completedStep = (run, outcome, summary, now, effects) => ({
5578
+ stepId: run.currentStepId,
5579
+ stepDigest: run.currentStepDigest,
5580
+ outcome,
5581
+ summary,
5582
+ ...effects.workspaceCwd ? { workspaceCwd: effects.workspaceCwd } : {},
5583
+ ...run.currentStepAttempts?.length ? { attempts: run.currentStepAttempts } : {},
5584
+ ...run.currentStepOmittedAttempts ? { omittedAttempts: run.currentStepOmittedAttempts } : {},
5585
+ completedAt: now
5586
+ });
5587
+ var advanceRun = (workflow, run, outcome, summary, now, effects = {}, options = {}) => {
5588
+ if (run.status !== "running") {
5589
+ throw new Error(`workflow is ${run.status}; only a running workflow can advance`);
5536
5590
  }
5537
- return reachable;
5538
- };
5539
- var stepsThatCanComplete = (definition, adjacency) => {
5540
- const canComplete = new Set(Object.entries(definition.steps).filter(([, step]) => Object.values(step.transitions).includes("$done")).map(([stepId]) => stepId));
5541
- const reverse = new Map;
5542
- for (const [source, targets] of Object.entries(adjacency)) {
5543
- for (const target of targets) {
5544
- reverse.set(target, [...reverse.get(target) ?? [], source]);
5545
- }
5591
+ const step = currentStep(workflow, run);
5592
+ if (!step) {
5593
+ throw new Error(`current step "${run.currentStepId}" no longer exists`);
5546
5594
  }
5547
- const pending = [...canComplete].sort(lexical);
5548
- while (pending.length > 0) {
5549
- const stepId = pending.pop();
5550
- if (!stepId)
5551
- continue;
5552
- for (const predecessor of (reverse.get(stepId) ?? []).sort(lexical)) {
5553
- if (canComplete.has(predecessor))
5554
- continue;
5555
- canComplete.add(predecessor);
5556
- pending.push(predecessor);
5557
- }
5595
+ if (step.gate?.submitOutcome === outcome) {
5596
+ throw new Error(`outcome "${outcome}" must be submitted through the configured gate`);
5558
5597
  }
5559
- return canComplete;
5560
- };
5561
- var stronglyConnectedComponents = (definition, adjacency) => {
5562
- let nextIndex = 0;
5563
- const indexes = new Map;
5564
- const lowLinks = new Map;
5565
- const stack = [];
5566
- const onStack = new Set;
5567
- const components = [];
5568
- const visit = (stepId) => {
5569
- indexes.set(stepId, nextIndex);
5570
- lowLinks.set(stepId, nextIndex);
5571
- nextIndex += 1;
5572
- stack.push(stepId);
5573
- onStack.add(stepId);
5574
- for (const target of adjacency[stepId] ?? []) {
5575
- if (!indexes.has(target)) {
5576
- visit(target);
5577
- lowLinks.set(stepId, Math.min(lowLinks.get(stepId) ?? 0, lowLinks.get(target) ?? 0));
5578
- } else if (onStack.has(target)) {
5579
- lowLinks.set(stepId, Math.min(lowLinks.get(stepId) ?? 0, indexes.get(target) ?? 0));
5580
- }
5598
+ const target = step.transitions[outcome];
5599
+ if (!target) {
5600
+ throw new Error(`outcome "${outcome}" is not valid for step "${run.currentStepId}"`);
5601
+ }
5602
+ const shouldBindWorkspace = step.workspace?.bindOn.includes(outcome) ?? false;
5603
+ if (shouldBindWorkspace !== Boolean(effects.workspaceCwd)) {
5604
+ throw new Error(shouldBindWorkspace ? `outcome "${outcome}" requires a validated workspace binding` : `outcome "${outcome}" cannot bind a workspace`);
5605
+ }
5606
+ if (shouldBindWorkspace && run.restartWorkspaceCwd !== undefined && effects.workspaceCwd !== run.restartWorkspaceCwd) {
5607
+ throw new Error(`restarted workflow must rebind workspace "${run.restartWorkspaceCwd}"`);
5608
+ }
5609
+ if (target === "$pause") {
5610
+ return withRunUpdate(run, {
5611
+ status: "paused",
5612
+ pausedFrom: "running",
5613
+ pauseReason: summary || `Step "${run.currentStepId}" requested a pause`,
5614
+ lastSummary: summary,
5615
+ resumeInput: undefined
5616
+ }, now);
5617
+ }
5618
+ const completed = completedStep(run, outcome, summary, now, effects);
5619
+ const cwd = effects.workspaceCwd ?? run.cwd;
5620
+ if (target === "$done") {
5621
+ if (run.restartWorkspaceCwd !== undefined) {
5622
+ throw new Error(`restarted workflow completed before rebinding workspace "${run.restartWorkspaceCwd}"`);
5581
5623
  }
5582
- if (lowLinks.get(stepId) !== indexes.get(stepId))
5583
- return;
5584
- const component = [];
5585
- let member;
5586
- do {
5587
- member = stack.pop();
5588
- if (!member)
5589
- break;
5590
- onStack.delete(member);
5591
- component.push(member);
5592
- } while (member !== stepId);
5593
- components.push(component.sort(lexical));
5594
- };
5595
- for (const stepId of Object.keys(definition.steps).sort(lexical)) {
5596
- if (!indexes.has(stepId))
5597
- visit(stepId);
5624
+ return withRunUpdate(run, {
5625
+ status: "completed",
5626
+ history: [...run.history, completed],
5627
+ currentStepAttempts: undefined,
5628
+ currentStepOmittedAttempts: undefined,
5629
+ ...cwd ? { cwd } : {},
5630
+ ...effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {},
5631
+ stepHandoff: summary,
5632
+ lastSummary: summary,
5633
+ gateArtifact: "",
5634
+ gateFeedback: "",
5635
+ pausedFrom: undefined,
5636
+ pendingGate: undefined,
5637
+ resumeInput: undefined
5638
+ }, now);
5598
5639
  }
5599
- return components.sort((left, right) => lexical(left.join("\x00"), right.join("\x00")));
5640
+ if (!workflow.definition.steps[target]) {
5641
+ throw new Error(`transition target "${target}" does not exist`);
5642
+ }
5643
+ const preservesGateRevisionContext = target === run.currentStepId && (options.sameStepHumanGateRevision || Boolean(run.gateArtifact));
5644
+ const nextVisitCount = (run.visits[target] ?? 0) + 1;
5645
+ const isOverVisitLimit = !options.sameStepHumanGateRevision && nextVisitCount > workflow.definition.maxStepVisits;
5646
+ const visitLimitChanges = isOverVisitLimit ? {
5647
+ status: "paused",
5648
+ pausedFrom: "running",
5649
+ pauseReason: `Step "${target}" exceeded maxStepVisits (${workflow.definition.maxStepVisits})`,
5650
+ failedStepId: target
5651
+ } : {
5652
+ status: "running",
5653
+ pausedFrom: undefined,
5654
+ pauseReason: undefined,
5655
+ failedStepId: undefined
5656
+ };
5657
+ return withRunUpdate(run, {
5658
+ ...visitLimitChanges,
5659
+ currentStepId: target,
5660
+ currentStepDigest: workflow.stepDigests[target] ?? "",
5661
+ visits: { ...run.visits, [target]: nextVisitCount },
5662
+ history: [...run.history, completed],
5663
+ currentStepAttempts: undefined,
5664
+ currentStepOmittedAttempts: undefined,
5665
+ ...cwd ? { cwd } : {},
5666
+ ...effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {},
5667
+ stepHandoff: preservesGateRevisionContext ? run.stepHandoff : summary,
5668
+ lastSummary: summary,
5669
+ gateArtifact: preservesGateRevisionContext ? run.gateArtifact : "",
5670
+ gateFeedback: preservesGateRevisionContext ? run.gateFeedback : "",
5671
+ resumeInput: undefined
5672
+ }, now);
5600
5673
  };
5601
- var isCycle = (adjacency, component) => {
5602
- if (component.length > 1)
5603
- return true;
5604
- const [stepId] = component;
5605
- return Boolean(stepId && adjacency[stepId]?.includes(stepId));
5674
+
5675
+ // src/engine/gate-transitions.ts
5676
+ var GATE_FEEDBACK_TRUNCATION_SUFFIX = `
5677
+ [gate feedback truncated by Pi Workflows]`;
5678
+ var MAX_GATE_REJECTION_SUMMARY_CHARS = 500;
5679
+ var boundedGateFeedback = (feedback) => feedback.length <= MAX_GATE_FEEDBACK_CHARS ? feedback : `${feedback.slice(0, MAX_GATE_FEEDBACK_CHARS - GATE_FEEDBACK_TRUNCATION_SUFFIX.length)}${GATE_FEEDBACK_TRUNCATION_SUFFIX}`;
5680
+ var gateRejectionSummary = (feedback) => {
5681
+ const compact = feedback.trim().replace(/\s+/g, " ");
5682
+ if (!compact)
5683
+ return "Gate rejected";
5684
+ const summary = `Gate rejected: ${compact}`;
5685
+ return summary.length <= MAX_GATE_REJECTION_SUMMARY_CHARS ? summary : `${summary.slice(0, MAX_GATE_REJECTION_SUMMARY_CHARS - 1)}…`;
5606
5686
  };
5607
- function analyzeWorkflow(definition) {
5608
- const adjacency = adjacencyFor(definition);
5609
- const reachable = reachableSteps(definition, adjacency);
5610
- const canComplete = stepsThatCanComplete(definition, adjacency);
5611
- const issues = [];
5612
- const stranded = [...reachable].filter((stepId) => !canComplete.has(stepId)).sort(lexical);
5613
- if (!canComplete.has(definition.start)) {
5614
- issues.push({
5615
- level: "error",
5616
- code: "no-completion-path",
5617
- steps: [definition.start],
5618
- message: `start step ${definition.start} cannot reach $done`
5619
- });
5687
+ var beginGate = (workflow, run, outcome, artifact, requestId, now, summary) => {
5688
+ if (run.status !== "running") {
5689
+ throw new Error(`workflow is ${run.status}; gate submission requires a running workflow`);
5620
5690
  }
5621
- if (stranded.length > 0) {
5622
- issues.push({
5623
- level: "error",
5624
- code: "reachable-step-cannot-reach-done",
5625
- steps: stranded,
5626
- message: `reachable step${stranded.length === 1 ? "" : "s"} ${stranded.join(", ")} cannot reach $done`
5627
- });
5691
+ const step = currentStep(workflow, run);
5692
+ if (!step?.gate) {
5693
+ throw new Error(`step "${run.currentStepId}" has no gate`);
5628
5694
  }
5629
- const unreachable = Object.keys(definition.steps).filter((stepId) => !reachable.has(stepId)).sort(lexical);
5630
- if (unreachable.length > 0) {
5631
- issues.push({
5632
- level: "warning",
5633
- code: "unreachable-steps",
5634
- steps: unreachable,
5635
- message: `unreachable step${unreachable.length === 1 ? "" : "s"}: ${unreachable.join(", ")}`
5636
- });
5695
+ if (outcome !== step.gate.submitOutcome) {
5696
+ throw new Error(`gate expects outcome "${step.gate.submitOutcome}"`);
5637
5697
  }
5638
- for (const component of stronglyConnectedComponents(definition, adjacency)) {
5639
- if (!isCycle(adjacency, component))
5640
- continue;
5641
- const componentIsReachable = component.some((stepId) => reachable.has(stepId));
5642
- const componentCanReachDone = component.some((stepId) => canComplete.has(stepId));
5643
- issues.push({
5644
- level: "warning",
5645
- code: "cycle",
5646
- steps: component,
5647
- reachable: componentIsReachable,
5648
- canReachDone: componentCanReachDone,
5649
- message: `${componentIsReachable ? "reachable" : "unreachable"} cyclic component: ${component.join(", ")}; ${componentCanReachDone ? "an exit can reach $done" : "no member can reach $done"}; maxStepVisits=${definition.maxStepVisits} bounds uninterrupted graph cycling`
5650
- });
5698
+ if (!artifact.trim()) {
5699
+ throw new Error("gate submission requires a non-empty artifact");
5651
5700
  }
5652
- return {
5653
- workflowId: definition.id,
5654
- maxStepVisits: definition.maxStepVisits,
5655
- reachableSteps: [...reachable].sort(lexical),
5656
- issues
5657
- };
5658
- }
5659
- var escapeMarkdown = (value) => value.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
5660
- function formatWorkflowDoctor(reports) {
5661
- const lines = ["# Workflow doctor", ""];
5662
- for (const report of reports) {
5663
- const errors = report.issues.filter((issue) => issue.level === "error");
5664
- const warnings = report.issues.filter((issue) => issue.level === "warning");
5665
- lines.push(`## ${report.workflowId}`, "", `Result: ${errors.length > 0 ? "ERROR" : warnings.length > 0 ? "WARNING" : "PASS"}`, "", `Runtime loop guard: automatic graph advancement enters each step at most ${report.maxStepVisits} time${report.maxStepVisits === 1 ? "" : "s"} before the next attempted entry pauses the run. An explicit human rejection back to the same gated step bypasses that check for its transition because every revision awaits another decision; the visit is still recorded. This bounds unattended cycling; it does not guarantee $done or bound time spent inside a step or gate.`, "");
5666
- if (report.issues.length === 0) {
5667
- lines.push("- No liveness issues found.", "");
5668
- continue;
5669
- }
5670
- lines.push(...report.issues.map((issue) => `- ${issue.level.toUpperCase()} \`${issue.code}\`: ${escapeMarkdown(issue.message)}`), "");
5701
+ if (!summary.trim()) {
5702
+ throw new Error("gate submission requires a non-empty summary");
5671
5703
  }
5672
- return lines.join(`
5673
- `).trimEnd();
5674
- }
5675
-
5676
- // src/workflow-list.ts
5677
- function escapeMarkdownTableCell(value) {
5678
- return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replace(/\r\n|\r|\n/g, " ");
5679
- }
5680
- function formatWorkflowList(workflows) {
5681
- return [
5682
- "| Workflow | Command | Description |",
5683
- "| --- | --- | --- |",
5684
- ...workflows.map((workflow) => `| \`${workflow.id}\` | \`/${workflow.command}\` | ${escapeMarkdownTableCell(workflow.description)} |`)
5685
- ].join(`
5686
- `);
5687
- }
5688
-
5689
- // src/harness/start-actions.ts
5690
- function isCurrentSession(session, sessionEpoch) {
5691
- return session.isSessionActive && session.sessionEpoch === sessionEpoch;
5692
- }
5693
- async function listWorkflows(context) {
5694
- const workflows = [...this.catalog.workflows.values()].sort((left, right) => left.definition.id.localeCompare(right.definition.id));
5695
- if (workflows.length === 0) {
5696
- context.ui.notify(`No workflows loaded from ${this.catalog.userDirectory}`, this.catalog.diagnostics.length > 0 ? "warning" : "info");
5697
- return;
5704
+ if (!requestId)
5705
+ throw new Error("gate submission requires a request id");
5706
+ return withRunUpdate(run, {
5707
+ status: "awaiting-gate",
5708
+ pendingGate: {
5709
+ provider: step.gate.provider,
5710
+ requestId,
5711
+ stepId: run.currentStepId,
5712
+ artifact,
5713
+ summary,
5714
+ submittedOutcome: outcome,
5715
+ requestedAt: now
5716
+ }
5717
+ }, now);
5718
+ };
5719
+ var attachGateReviewId = (run, reviewId, now) => {
5720
+ if (!run.pendingGate)
5721
+ throw new Error("workflow has no pending gate");
5722
+ if (run.pendingGate.provider !== "plannotator") {
5723
+ throw new Error("only a Plannotator gate can have a review id");
5698
5724
  }
5699
- this.pi.sendMessage({
5700
- customType: "workflow-list",
5701
- content: formatWorkflowList(workflows.map((workflow) => workflow.definition)),
5702
- display: true
5703
- });
5704
- }
5705
- async function doctorWorkflows(workflowId, context) {
5706
- const catalog = await this.dependencies.loadCatalog({
5707
- cwd: context.cwd,
5708
- projectTrusted: context.isProjectTrusted()
5709
- });
5710
- if (catalog.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
5711
- context.ui.notify(`Workflow configuration errors:
5712
- ${formatCatalogDiagnostics(catalog)}`, "warning");
5725
+ return withRunUpdate(run, { pendingGate: { ...run.pendingGate, reviewId } }, now);
5726
+ };
5727
+ var failGate = (run, reason, now) => {
5728
+ if (!run.pendingGate)
5729
+ return run;
5730
+ return withRunUpdate(run, {
5731
+ status: "running",
5732
+ pendingGate: undefined,
5733
+ gateArtifact: run.pendingGate.artifact,
5734
+ gateFeedback: boundedGateFeedback(reason),
5735
+ pausedFrom: undefined,
5736
+ pauseReason: undefined,
5737
+ failedStepId: undefined
5738
+ }, now);
5739
+ };
5740
+ var storeGateResolution = (run, resolution, now) => {
5741
+ if (!run.pendingGate)
5742
+ return run;
5743
+ return withRunUpdate(run, {
5744
+ pendingGate: {
5745
+ ...run.pendingGate,
5746
+ resolution: {
5747
+ ...resolution,
5748
+ feedback: boundedGateFeedback(resolution.feedback)
5749
+ }
5750
+ }
5751
+ }, now);
5752
+ };
5753
+ var resolveGate = (workflow, run, resolution, now) => {
5754
+ const pendingGate = run.pendingGate;
5755
+ if (!pendingGate)
5756
+ throw new Error("workflow has no pending gate");
5757
+ const step = workflow.definition.steps[pendingGate.stepId];
5758
+ if (!step?.gate) {
5759
+ throw new Error(`gated step "${pendingGate.stepId}" no longer exists`);
5713
5760
  }
5714
- const selected = workflowId ? [catalog.workflows.get(workflowId)].filter((workflow) => workflow !== undefined) : [...catalog.workflows.values()].sort((left, right) => left.definition.id.localeCompare(right.definition.id));
5715
- if (workflowId && selected.length === 0) {
5716
- context.ui.notify(`Workflow "${workflowId}" is not loaded`, "error");
5717
- return;
5761
+ if (run.currentStepId !== pendingGate.stepId) {
5762
+ throw new Error("gate result does not match the current step");
5718
5763
  }
5719
- if (selected.length === 0) {
5720
- context.ui.notify(`No workflows loaded from ${catalog.userDirectory}`, catalog.diagnostics.length > 0 ? "warning" : "info");
5721
- return;
5764
+ const outcome = resolution.approved ? step.gate.approvedOutcome : step.gate.rejectedOutcome;
5765
+ const feedback = boundedGateFeedback(resolution.feedback);
5766
+ const stepStructuralDigest = workflow.stepStructuralDigests[pendingGate.stepId] ?? "";
5767
+ if (resolution.approved && !stepStructuralDigest) {
5768
+ throw new Error(`gated step "${pendingGate.stepId}" has no structural digest`);
5722
5769
  }
5723
- this.pi.sendMessage({
5724
- customType: "workflow-doctor",
5725
- content: formatWorkflowDoctor(selected.map((workflow) => analyzeWorkflow(workflow.definition))),
5726
- display: true
5770
+ const summary = resolution.approved ? pendingGate.summary ?? "" : gateRejectionSummary(feedback);
5771
+ const decidedRun = recordCurrentGateDecision(run, {
5772
+ provider: pendingGate.provider,
5773
+ requestId: pendingGate.requestId,
5774
+ approved: resolution.approved,
5775
+ feedback,
5776
+ resolvedAt: resolution.resolvedAt,
5777
+ ...pendingGate.reviewId ? { reviewId: pendingGate.reviewId } : {}
5778
+ }, now);
5779
+ const runnableRun = withRunUpdate(decidedRun, {
5780
+ status: "running",
5781
+ pendingGate: undefined,
5782
+ pausedFrom: undefined,
5783
+ pauseReason: undefined,
5784
+ gateArtifact: resolution.approved ? "" : pendingGate.artifact,
5785
+ gateFeedback: resolution.approved ? "" : feedback
5786
+ }, now);
5787
+ const isSameStepHumanRevision = !resolution.approved && step.transitions[outcome] === pendingGate.stepId;
5788
+ const advanced = advanceRun(workflow, runnableRun, outcome, summary, now, {}, {
5789
+ sameStepHumanGateRevision: isSameStepHumanRevision
5727
5790
  });
5728
- }
5729
- async function startNow(workflowId, input, startContext, sessionEpoch) {
5730
- const { context } = startContext;
5731
- if (this.activeDelegation) {
5732
- context.ui.notify(`Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`, "warning");
5733
- return;
5734
- }
5735
- if (this.run && this.run.status !== "completed" && this.run.status !== "aborted") {
5736
- context.ui.notify(`Workflow "${this.run.workflowId}" is ${this.run.status}; resume or abort it first`, "warning");
5737
- return;
5791
+ const completedApprovedGate = resolution.approved && advanced.history.length > runnableRun.history.length;
5792
+ const history = completedApprovedGate ? advanced.history.map((entry, index) => index === advanced.history.length - 1 ? {
5793
+ ...entry,
5794
+ artifact: pendingGate.artifact,
5795
+ approval: {
5796
+ requestId: pendingGate.requestId,
5797
+ artifact: pendingGate.artifact,
5798
+ feedback,
5799
+ stepStructuralDigest
5800
+ }
5801
+ } : entry) : advanced.history;
5802
+ return {
5803
+ ...advanced,
5804
+ history,
5805
+ ...completedApprovedGate ? {
5806
+ reviewedArtifact: pendingGate.artifact,
5807
+ reviewedFeedback: feedback
5808
+ } : {},
5809
+ gateArtifact: resolution.approved ? "" : pendingGate.artifact,
5810
+ gateFeedback: resolution.approved ? "" : feedback
5811
+ };
5812
+ };
5813
+ // src/engine/run-lifecycle.ts
5814
+ var completedWorkspaceCwd = (run) => {
5815
+ for (let index = run.history.length - 1;index >= 0; index -= 1) {
5816
+ const workspaceCwd = run.history[index]?.workspaceCwd;
5817
+ if (workspaceCwd)
5818
+ return workspaceCwd;
5738
5819
  }
5739
- if (!context.isIdle()) {
5740
- context.abort();
5741
- await startContext.waitForIdle();
5820
+ return;
5821
+ };
5822
+ var allowedOutcomes = (workflow, run) => {
5823
+ const step = currentStep(workflow, run);
5824
+ if (!step)
5825
+ return [];
5826
+ const gateResolutionOutcomes = step.gate ? new Set([step.gate.approvedOutcome, step.gate.rejectedOutcome]) : undefined;
5827
+ return [
5828
+ ...Object.keys(step.transitions).filter((outcome) => !gateResolutionOutcomes?.has(outcome)),
5829
+ ...step.gate ? [step.gate.submitOutcome] : []
5830
+ ];
5831
+ };
5832
+ var pauseRun = (run, reason, now) => {
5833
+ if (run.status !== "running" && run.status !== "awaiting-gate") {
5834
+ return withRunUpdate(run, { pauseReason: reason || run.pauseReason }, now);
5742
5835
  }
5743
- if (!isCurrentSession(this, sessionEpoch)) {
5744
- context.ui.notify("Workflow start was superseded by a session change", "warning");
5745
- return;
5836
+ return withRunUpdate(run, {
5837
+ status: "paused",
5838
+ pausedFrom: run.status,
5839
+ pauseReason: reason || `Paused during step "${run.currentStepId}"`,
5840
+ failedStepId: undefined
5841
+ }, now);
5842
+ };
5843
+ var failRun = (run, reason, now) => {
5844
+ const pausedRun = pauseRun(run, reason, now);
5845
+ return pausedRun.status === "paused" ? { ...pausedRun, failedStepId: pausedRun.currentStepId } : pausedRun;
5846
+ };
5847
+ var resumeRun = (run, now) => {
5848
+ if (run.status !== "paused")
5849
+ return run;
5850
+ return withRunUpdate(run, {
5851
+ status: run.pausedFrom ?? (run.pendingGate ? "awaiting-gate" : "running"),
5852
+ pauseReason: undefined,
5853
+ pausedFrom: undefined,
5854
+ failedStepId: undefined
5855
+ }, now);
5856
+ };
5857
+ var setResumeInput = (run, input, now) => withRunUpdate(run, { resumeInput: input.trim() || undefined }, now);
5858
+ var abortRun = (run, reason, now) => withRunUpdate(run, {
5859
+ status: "aborted",
5860
+ pauseReason: reason || "Aborted by user",
5861
+ pausedFrom: undefined,
5862
+ failedStepId: undefined,
5863
+ pendingGate: undefined,
5864
+ resumeInput: undefined
5865
+ }, now);
5866
+ var restartRun = (workflow, run, input, baselineTools, now) => {
5867
+ if (run.status !== "completed") {
5868
+ throw new Error("only a completed workflow can be restarted");
5746
5869
  }
5747
- this.captureSkills(startContext.skills());
5748
- if (!await this.reloadCatalog(context, false)) {
5749
- context.ui.notify("Workflow start was superseded by a newer configuration load", "warning");
5750
- return;
5870
+ if (!run.startCwd) {
5871
+ throw new Error("the completed workflow has no captured start directory; start a new workflow instead");
5751
5872
  }
5752
- if (!isCurrentSession(this, sessionEpoch)) {
5753
- context.ui.notify("Workflow start was superseded by a session change", "warning");
5754
- return;
5873
+ const previousIteration = run.iteration ?? 1;
5874
+ if (!Number.isSafeInteger(previousIteration) || previousIteration < 1) {
5875
+ throw new Error("the completed workflow has an invalid iteration number");
5755
5876
  }
5756
- const workflow = this.catalog.workflows.get(workflowId);
5757
- if (!workflow) {
5758
- context.ui.notify(`Workflow "${workflowId}" is not loaded`, "error");
5759
- return;
5877
+ if (previousIteration >= Number.MAX_SAFE_INTEGER) {
5878
+ throw new Error("the workflow iteration limit has been reached");
5760
5879
  }
5761
- const livenessErrors = analyzeWorkflow(workflow.definition).issues.filter((issue) => issue.level === "error");
5762
- if (livenessErrors.length > 0) {
5763
- context.ui.notify(`Cannot start workflow; run /workflow-doctor ${workflowId}:
5764
- ${livenessErrors.map((issue) => issue.message).join(`
5765
- `)}`, "error");
5766
- return;
5880
+ const workspaceCwd = completedWorkspaceCwd(run);
5881
+ if (workspaceCwd && run.cwd !== workspaceCwd) {
5882
+ throw new Error("the completed workflow workspace does not match its recorded binding");
5767
5883
  }
5768
- const preflightErrors = this.preflight(workflow, workflow.definition.start);
5769
- if (preflightErrors.length > 0) {
5770
- context.ui.notify(`Cannot start workflow:
5771
- ${preflightErrors.join(`
5772
- `)}`, "error");
5773
- return;
5774
- }
5775
- let canonicalStartCwd;
5776
- try {
5777
- canonicalStartCwd = this.dependencies.resolveWorkspaceDirectory({
5778
- candidateCwd: context.cwd,
5779
- startCwd: context.cwd,
5780
- allowedRoots: ["."]
5781
- });
5782
- } catch (error) {
5783
- context.ui.notify(`Cannot capture workflow working directory: ${error instanceof Error ? error.message : String(error)}`, "error");
5784
- return;
5785
- }
5786
- this.run = createRun(workflow, input.trim(), this.pi.getActiveTools(), this.dependencies.createRequestId(), this.dependencies.now(), canonicalStartCwd);
5787
- this.persist();
5788
- this.isolateMainSessionTools();
5789
- this.updateStatus();
5790
- this.launchCurrentStep(workflow);
5791
- }
5792
- async function reloadNow(context) {
5793
- if (this.run && (this.run.status === "running" || this.run.status === "awaiting-gate")) {
5794
- context.ui.notify("Pause the workflow before reloading its configuration", "warning");
5795
- return;
5796
- }
5797
- this.captureSkills(context.getSystemPromptOptions().skills);
5798
- await this.reloadCatalog(context, true);
5799
- }
5800
- function createStartActions() {
5801
- return { listWorkflows, doctorWorkflows, startNow, reloadNow };
5802
- }
5803
-
5804
- // src/engine/transition-helpers.ts
5805
- var currentStep = (workflow, run) => workflow.definition.steps[run.currentStepId];
5806
- var withRunUpdate = (run, changes, now) => ({ ...run, ...changes, updatedAt: now });
5807
-
5808
- // src/engine/run-advance.ts
5809
- var completedStep = (run, outcome, summary, now, effects) => ({
5810
- stepId: run.currentStepId,
5811
- stepDigest: run.currentStepDigest,
5812
- outcome,
5813
- summary,
5814
- ...effects.workspaceCwd ? { workspaceCwd: effects.workspaceCwd } : {},
5815
- ...run.currentStepAttempts?.length ? { attempts: run.currentStepAttempts } : {},
5816
- ...run.currentStepOmittedAttempts ? { omittedAttempts: run.currentStepOmittedAttempts } : {},
5817
- completedAt: now
5818
- });
5819
- var advanceRun = (workflow, run, outcome, summary, now, effects = {}, options = {}) => {
5820
- if (run.status !== "running") {
5821
- throw new Error(`workflow is ${run.status}; only a running workflow can advance`);
5822
- }
5823
- const step = currentStep(workflow, run);
5824
- if (!step) {
5825
- throw new Error(`current step "${run.currentStepId}" no longer exists`);
5826
- }
5827
- if (step.gate?.submitOutcome === outcome) {
5828
- throw new Error(`outcome "${outcome}" must be submitted through the configured gate`);
5829
- }
5830
- const target = step.transitions[outcome];
5831
- if (!target) {
5832
- throw new Error(`outcome "${outcome}" is not valid for step "${run.currentStepId}"`);
5833
- }
5834
- const shouldBindWorkspace = step.workspace?.bindOn.includes(outcome) ?? false;
5835
- if (shouldBindWorkspace !== Boolean(effects.workspaceCwd)) {
5836
- throw new Error(shouldBindWorkspace ? `outcome "${outcome}" requires a validated workspace binding` : `outcome "${outcome}" cannot bind a workspace`);
5837
- }
5838
- if (target === "$pause") {
5839
- return withRunUpdate(run, {
5840
- status: "paused",
5841
- pausedFrom: "running",
5842
- pauseReason: summary || `Step "${run.currentStepId}" requested a pause`,
5843
- lastSummary: summary,
5844
- resumeInput: undefined
5845
- }, now);
5846
- }
5847
- const completed = completedStep(run, outcome, summary, now, effects);
5848
- const cwd = effects.workspaceCwd ?? run.cwd;
5849
- if (target === "$done") {
5850
- return withRunUpdate(run, {
5851
- status: "completed",
5852
- history: [...run.history, completed],
5853
- currentStepAttempts: undefined,
5854
- currentStepOmittedAttempts: undefined,
5855
- ...cwd ? { cwd } : {},
5856
- stepHandoff: summary,
5857
- lastSummary: summary,
5858
- gateArtifact: "",
5859
- gateFeedback: "",
5860
- pausedFrom: undefined,
5861
- pendingGate: undefined,
5862
- resumeInput: undefined
5863
- }, now);
5884
+ const restarted = createRun(workflow, input, baselineTools, run.runId, now, run.startCwd, previousIteration + 1);
5885
+ return {
5886
+ ...restarted,
5887
+ stepHandoff: run.lastSummary,
5888
+ lastSummary: run.lastSummary,
5889
+ ...workspaceCwd ? { restartWorkspaceCwd: workspaceCwd } : {}
5890
+ };
5891
+ };
5892
+ // src/engine/reconciliation-history.ts
5893
+ var isApprovedGateEntry = (workflow, entry) => {
5894
+ const gate = workflow.definition.steps[entry.stepId]?.gate;
5895
+ return gate !== undefined && entry.outcome === gate.approvedOutcome;
5896
+ };
5897
+ var latestApprovedGateEntry = (workflow, history) => {
5898
+ for (let index = history.length - 1;index >= 0; index -= 1) {
5899
+ const entry = history[index];
5900
+ if (entry && isApprovedGateEntry(workflow, entry))
5901
+ return { entry, index };
5864
5902
  }
5865
- if (!workflow.definition.steps[target]) {
5866
- throw new Error(`transition target "${target}" does not exist`);
5903
+ return;
5904
+ };
5905
+ var rebuildVisits = (history, currentStepId) => {
5906
+ const visitedStepIds = [
5907
+ ...history.map((entry) => entry.stepId),
5908
+ currentStepId
5909
+ ];
5910
+ return visitedStepIds.reduce((visits, stepId) => ({
5911
+ ...visits,
5912
+ [stepId]: (visits[stepId] ?? 0) + 1
5913
+ }), {});
5914
+ };
5915
+ var retainedWorkspaceCwd = (run, history) => {
5916
+ for (let index = history.length - 1;index >= 0; index -= 1) {
5917
+ const cwd = history[index]?.workspaceCwd;
5918
+ if (cwd)
5919
+ return cwd;
5867
5920
  }
5868
- const preservesGateRevisionContext = target === run.currentStepId && (options.sameStepHumanGateRevision || Boolean(run.gateArtifact));
5869
- const nextVisitCount = (run.visits[target] ?? 0) + 1;
5870
- const isOverVisitLimit = !options.sameStepHumanGateRevision && nextVisitCount > workflow.definition.maxStepVisits;
5871
- const visitLimitChanges = isOverVisitLimit ? {
5872
- status: "paused",
5873
- pausedFrom: "running",
5874
- pauseReason: `Step "${target}" exceeded maxStepVisits (${workflow.definition.maxStepVisits})`,
5875
- failedStepId: target
5876
- } : {
5877
- status: "running",
5878
- pausedFrom: undefined,
5879
- pauseReason: undefined,
5880
- failedStepId: undefined
5881
- };
5882
- return withRunUpdate(run, {
5883
- ...visitLimitChanges,
5884
- currentStepId: target,
5885
- currentStepDigest: workflow.stepDigests[target] ?? "",
5886
- visits: { ...run.visits, [target]: nextVisitCount },
5887
- history: [...run.history, completed],
5888
- currentStepAttempts: undefined,
5889
- currentStepOmittedAttempts: undefined,
5890
- ...cwd ? { cwd } : {},
5891
- stepHandoff: preservesGateRevisionContext ? run.stepHandoff : summary,
5892
- lastSummary: summary,
5893
- gateArtifact: preservesGateRevisionContext ? run.gateArtifact : "",
5894
- gateFeedback: preservesGateRevisionContext ? run.gateFeedback : "",
5895
- resumeInput: undefined
5896
- }, now);
5921
+ return run.startCwd ?? run.cwd;
5922
+ };
5923
+ var retainedReviewedApproval = (workflow, history) => {
5924
+ const retainedApproval = latestApprovedGateEntry(workflow, history);
5925
+ const approval = retainedApproval?.entry.approval;
5926
+ return approval ? { artifact: approval.artifact, feedback: approval.feedback } : undefined;
5927
+ };
5928
+ var refreshApprovedGateHistory = (run, workflow) => {
5929
+ const history = run.history.map((entry) => {
5930
+ const gate = workflow.definition.steps[entry.stepId]?.gate;
5931
+ const currentDigest = workflow.stepDigests[entry.stepId];
5932
+ const currentStructuralDigest = workflow.stepStructuralDigests[entry.stepId];
5933
+ const shouldRefresh = gate !== undefined && typeof currentDigest === "string" && currentDigest.length > 0 && typeof currentStructuralDigest === "string" && currentStructuralDigest.length > 0 && entry.outcome === gate.approvedOutcome && entry.approval?.stepStructuralDigest === currentStructuralDigest && entry.stepDigest !== currentDigest;
5934
+ return shouldRefresh ? { ...entry, stepDigest: currentDigest } : entry;
5935
+ });
5936
+ const hasChanged = history.some((entry, index) => entry !== run.history[index]);
5937
+ return hasChanged ? { ...run, history } : run;
5897
5938
  };
5898
5939
 
5899
- // src/engine/gate-transitions.ts
5900
- var GATE_FEEDBACK_TRUNCATION_SUFFIX = `
5901
- [gate feedback truncated by Pi Workflows]`;
5902
- var MAX_GATE_REJECTION_SUMMARY_CHARS = 500;
5903
- var boundedGateFeedback = (feedback) => feedback.length <= MAX_GATE_FEEDBACK_CHARS ? feedback : `${feedback.slice(0, MAX_GATE_FEEDBACK_CHARS - GATE_FEEDBACK_TRUNCATION_SUFFIX.length)}${GATE_FEEDBACK_TRUNCATION_SUFFIX}`;
5904
- var gateRejectionSummary = (feedback) => {
5905
- const compact = feedback.trim().replace(/\s+/g, " ");
5906
- if (!compact)
5907
- return "Gate rejected";
5908
- const summary = `Gate rejected: ${compact}`;
5909
- return summary.length <= MAX_GATE_REJECTION_SUMMARY_CHARS ? summary : `${summary.slice(0, MAX_GATE_REJECTION_SUMMARY_CHARS - 1)}…`;
5940
+ // src/engine/run-workflow-validation.ts
5941
+ var sameVisitCounts = (actual, expected) => {
5942
+ const actualEntries = Object.entries(actual).sort(([left], [right]) => left.localeCompare(right));
5943
+ const expectedEntries = Object.entries(expected).sort(([left], [right]) => left.localeCompare(right));
5944
+ return actualEntries.length === expectedEntries.length && actualEntries.every(([stepId, count], index) => expectedEntries[index]?.[0] === stepId && expectedEntries[index][1] === count);
5910
5945
  };
5911
- var beginGate = (workflow, run, outcome, artifact, requestId, now, summary) => {
5912
- if (run.status !== "running") {
5913
- throw new Error(`workflow is ${run.status}; gate submission requires a running workflow`);
5946
+ var validateHistoryMetadata = (workflow, step, entry) => {
5947
+ const shouldBind = step.workspace?.bindOn.includes(entry.outcome) === true;
5948
+ if (shouldBind !== (entry.workspaceCwd !== undefined)) {
5949
+ return shouldBind ? `history step "${entry.stepId}" is missing its workspace binding` : `history step "${entry.stepId}" has an unauthorized workspace binding`;
5914
5950
  }
5915
- const step = currentStep(workflow, run);
5916
- if (!step?.gate) {
5917
- throw new Error(`step "${run.currentStepId}" has no gate`);
5951
+ const isApprovedGate = step.gate !== undefined && entry.outcome === step.gate.approvedOutcome;
5952
+ if (isApprovedGate && !entry.approval) {
5953
+ return `history step "${entry.stepId}" is missing authoritative gate approval`;
5918
5954
  }
5919
- if (outcome !== step.gate.submitOutcome) {
5920
- throw new Error(`gate expects outcome "${step.gate.submitOutcome}"`);
5955
+ if (isApprovedGate && entry.artifact !== entry.approval?.artifact) {
5956
+ return `history step "${entry.stepId}" approval artifact is inconsistent`;
5921
5957
  }
5922
- if (!artifact.trim()) {
5923
- throw new Error("gate submission requires a non-empty artifact");
5958
+ if (!isApprovedGate && (entry.approval || entry.artifact !== undefined)) {
5959
+ return `history step "${entry.stepId}" has approval data for a non-approved outcome`;
5924
5960
  }
5925
- if (!summary.trim()) {
5926
- throw new Error("gate submission requires a non-empty summary");
5961
+ if (isApprovedGate && entry.approval?.stepStructuralDigest !== workflow.stepStructuralDigests[entry.stepId]) {
5962
+ return `history step "${entry.stepId}" approval does not match its configured structure`;
5927
5963
  }
5928
- if (!requestId)
5929
- throw new Error("gate submission requires a request id");
5930
- return withRunUpdate(run, {
5931
- status: "awaiting-gate",
5932
- pendingGate: {
5933
- provider: step.gate.provider,
5934
- requestId,
5935
- stepId: run.currentStepId,
5936
- artifact,
5937
- summary,
5938
- submittedOutcome: outcome,
5939
- requestedAt: now
5940
- }
5941
- }, now);
5964
+ return;
5942
5965
  };
5943
- var attachGateReviewId = (run, reviewId, now) => {
5944
- if (!run.pendingGate)
5945
- throw new Error("workflow has no pending gate");
5946
- if (run.pendingGate.provider !== "plannotator") {
5947
- throw new Error("only a Plannotator gate can have a review id");
5966
+ var validatePendingGate = (run, step) => {
5967
+ const pending = run.pendingGate;
5968
+ if (!pending)
5969
+ return;
5970
+ if (!step.gate || pending.stepId !== run.currentStepId || pending.provider !== step.gate.provider || pending.submittedOutcome !== step.gate.submitOutcome) {
5971
+ return "pending gate does not match the current workflow step";
5948
5972
  }
5949
- return withRunUpdate(run, { pendingGate: { ...run.pendingGate, reviewId } }, now);
5950
- };
5951
- var failGate = (run, reason, now) => {
5952
- if (!run.pendingGate)
5953
- return run;
5954
- return withRunUpdate(run, {
5955
- status: "running",
5956
- pendingGate: undefined,
5957
- gateArtifact: run.pendingGate.artifact,
5958
- gateFeedback: boundedGateFeedback(reason),
5959
- pausedFrom: undefined,
5960
- pauseReason: undefined,
5961
- failedStepId: undefined
5962
- }, now);
5973
+ if (pending.provider === "prompt" && pending.reviewId !== undefined) {
5974
+ return "built-in prompt gate cannot carry a Plannotator review id";
5975
+ }
5976
+ return;
5963
5977
  };
5964
- var storeGateResolution = (run, resolution, now) => {
5965
- if (!run.pendingGate)
5966
- return run;
5967
- return withRunUpdate(run, {
5968
- pendingGate: {
5969
- ...run.pendingGate,
5970
- resolution: {
5971
- ...resolution,
5972
- feedback: boundedGateFeedback(resolution.feedback)
5978
+ function validateRunWorkflowSemantics(run, workflow) {
5979
+ const sameWorkflowDigest = run.workflowDigest === workflow.digest;
5980
+ let expectedStepId = workflow.definition.start;
5981
+ let reachedDone = false;
5982
+ let boundWorkspaceCwd;
5983
+ let latestApproval;
5984
+ const expectedVisits = { [expectedStepId]: 1 };
5985
+ for (let index = 0;index < run.history.length; index += 1) {
5986
+ const entry = run.history[index];
5987
+ if (!entry)
5988
+ continue;
5989
+ if (reachedDone) {
5990
+ return "workflow history continues after a $done transition";
5991
+ }
5992
+ if (entry.stepId !== expectedStepId) {
5993
+ return `history step "${entry.stepId}" is not reachable after "${expectedStepId}"`;
5994
+ }
5995
+ const step = workflow.definition.steps[entry.stepId];
5996
+ const configuredDigest = workflow.stepDigests[entry.stepId];
5997
+ if (!step || entry.stepDigest !== configuredDigest) {
5998
+ return sameWorkflowDigest ? `history step "${entry.stepId}" does not match the active workflow digest` : undefined;
5999
+ }
6000
+ const metadataError = validateHistoryMetadata(workflow, step, entry);
6001
+ if (metadataError)
6002
+ return metadataError;
6003
+ if (entry.workspaceCwd) {
6004
+ if (boundWorkspaceCwd !== undefined && boundWorkspaceCwd !== entry.workspaceCwd) {
6005
+ return "workflow history attempts to replace an existing workspace binding";
5973
6006
  }
6007
+ boundWorkspaceCwd = entry.workspaceCwd;
5974
6008
  }
5975
- }, now);
5976
- };
5977
- var resolveGate = (workflow, run, resolution, now) => {
5978
- const pendingGate = run.pendingGate;
5979
- if (!pendingGate)
5980
- throw new Error("workflow has no pending gate");
5981
- const step = workflow.definition.steps[pendingGate.stepId];
5982
- if (!step?.gate) {
5983
- throw new Error(`gated step "${pendingGate.stepId}" no longer exists`);
6009
+ if (entry.approval) {
6010
+ latestApproval = {
6011
+ artifact: entry.approval.artifact,
6012
+ feedback: entry.approval.feedback
6013
+ };
6014
+ }
6015
+ const target = step.transitions[entry.outcome];
6016
+ if (!target) {
6017
+ return `history outcome "${entry.outcome}" is not configured for step "${entry.stepId}"`;
6018
+ }
6019
+ if (target === "$pause") {
6020
+ return `history step "${entry.stepId}" records a non-completing $pause transition`;
6021
+ }
6022
+ if (target === "$done") {
6023
+ if (index !== run.history.length - 1) {
6024
+ return "workflow history continues after a $done transition";
6025
+ }
6026
+ reachedDone = true;
6027
+ continue;
6028
+ }
6029
+ expectedStepId = target;
6030
+ expectedVisits[target] = (expectedVisits[target] ?? 0) + 1;
5984
6031
  }
5985
- if (run.currentStepId !== pendingGate.stepId) {
5986
- throw new Error("gate result does not match the current step");
6032
+ if (run.currentStepId !== expectedStepId) {
6033
+ return `current step "${run.currentStepId}" does not match reachable step "${expectedStepId}"`;
5987
6034
  }
5988
- const outcome = resolution.approved ? step.gate.approvedOutcome : step.gate.rejectedOutcome;
5989
- const feedback = boundedGateFeedback(resolution.feedback);
5990
- const stepStructuralDigest = workflow.stepStructuralDigests[pendingGate.stepId] ?? "";
5991
- if (resolution.approved && !stepStructuralDigest) {
5992
- throw new Error(`gated step "${pendingGate.stepId}" has no structural digest`);
6035
+ if (reachedDone !== (run.status === "completed")) {
6036
+ return reachedDone ? "a workflow that reached $done must be completed" : "a completed workflow has no $done transition in its history";
5993
6037
  }
5994
- const summary = resolution.approved ? pendingGate.summary ?? "" : gateRejectionSummary(feedback);
5995
- const decidedRun = recordCurrentGateDecision(run, {
5996
- provider: pendingGate.provider,
5997
- requestId: pendingGate.requestId,
5998
- approved: resolution.approved,
5999
- feedback,
6000
- resolvedAt: resolution.resolvedAt,
6001
- ...pendingGate.reviewId ? { reviewId: pendingGate.reviewId } : {}
6002
- }, now);
6003
- const runnableRun = withRunUpdate(decidedRun, {
6004
- status: "running",
6005
- pendingGate: undefined,
6006
- pausedFrom: undefined,
6007
- pauseReason: undefined,
6008
- gateArtifact: resolution.approved ? "" : pendingGate.artifact,
6009
- gateFeedback: resolution.approved ? "" : feedback
6010
- }, now);
6011
- const isSameStepHumanRevision = !resolution.approved && step.transitions[outcome] === pendingGate.stepId;
6012
- const advanced = advanceRun(workflow, runnableRun, outcome, summary, now, {}, {
6013
- sameStepHumanGateRevision: isSameStepHumanRevision
6014
- });
6015
- const completedApprovedGate = resolution.approved && advanced.history.length > runnableRun.history.length;
6016
- const history = completedApprovedGate ? advanced.history.map((entry, index) => index === advanced.history.length - 1 ? {
6017
- ...entry,
6018
- artifact: pendingGate.artifact,
6019
- approval: {
6020
- requestId: pendingGate.requestId,
6021
- artifact: pendingGate.artifact,
6022
- feedback,
6023
- stepStructuralDigest
6038
+ if (run.status === "completed" && run.currentStepDigest !== run.history.at(-1)?.stepDigest) {
6039
+ return "completed workflow current-step digest does not match its terminal history";
6040
+ }
6041
+ if (!sameVisitCounts(run.visits, expectedVisits)) {
6042
+ return "workflow visit counts do not match its execution history";
6043
+ }
6044
+ const reviewedArtifact = run.reviewedArtifact ?? "";
6045
+ const reviewedFeedback = run.reviewedFeedback ?? "";
6046
+ if (reviewedArtifact !== (latestApproval?.artifact ?? "") || reviewedFeedback !== (latestApproval?.feedback ?? "")) {
6047
+ return "reviewed artifact and feedback do not match authoritative approval history";
6048
+ }
6049
+ const currentStep2 = workflow.definition.steps[run.currentStepId];
6050
+ if (!currentStep2) {
6051
+ return `current step "${run.currentStepId}" is missing from the workflow`;
6052
+ }
6053
+ const currentStepChanged = run.currentStepDigest !== workflow.stepDigests[run.currentStepId];
6054
+ if (sameWorkflowDigest && currentStepChanged) {
6055
+ return `current step "${run.currentStepId}" does not match the active workflow digest`;
6056
+ }
6057
+ if (boundWorkspaceCwd && !currentStep2.subagent) {
6058
+ return `bound workflow current step "${run.currentStepId}" must use a subagent`;
6059
+ }
6060
+ if (currentStepChanged)
6061
+ return;
6062
+ return validatePendingGate(run, currentStep2);
6063
+ }
6064
+
6065
+ // src/engine/run-reconciliation.ts
6066
+ var reconcileRun = (run, workflow, now) => {
6067
+ if (run.workflowId !== workflow.definition.id) {
6068
+ return {
6069
+ changed: false,
6070
+ error: `run belongs to "${run.workflowId}", not "${workflow.definition.id}"`
6071
+ };
6072
+ }
6073
+ const reconciledRun = run.workflowDigest === workflow.digest ? run : refreshApprovedGateHistory(run, workflow);
6074
+ const changedHistoryIndex = run.workflowDigest === workflow.digest ? -1 : reconciledRun.history.findIndex((entry) => workflow.stepDigests[entry.stepId] !== entry.stepDigest);
6075
+ const changedHistoryEntry = changedHistoryIndex >= 0 ? reconciledRun.history[changedHistoryIndex] : undefined;
6076
+ if (changedHistoryEntry && !workflow.definition.steps[changedHistoryEntry.stepId]) {
6077
+ return {
6078
+ changed: true,
6079
+ error: "a completed step was removed; abort or restore the configuration"
6080
+ };
6081
+ }
6082
+ if (changedHistoryIndex < 0 && !workflow.definition.steps[run.currentStepId]) {
6083
+ return {
6084
+ changed: true,
6085
+ error: `current step "${run.currentStepId}" was removed; abort or restore the configuration`
6086
+ };
6087
+ }
6088
+ const semanticError = validateRunWorkflowSemantics(reconciledRun, workflow);
6089
+ if (semanticError) {
6090
+ return {
6091
+ changed: run.workflowDigest !== workflow.digest,
6092
+ error: `workflow checkpoint is inconsistent: ${semanticError}`
6093
+ };
6094
+ }
6095
+ if (run.workflowDigest === workflow.digest) {
6096
+ return { run, changed: false };
6097
+ }
6098
+ if (changedHistoryIndex >= 0) {
6099
+ const changedEntry = changedHistoryEntry;
6100
+ if (!changedEntry) {
6101
+ return { changed: true, error: "changed history entry is unavailable" };
6024
6102
  }
6025
- } : entry) : advanced.history;
6103
+ const retainedHistory = reconciledRun.history.slice(0, changedHistoryIndex);
6104
+ const restartedStep = changedEntry.stepId;
6105
+ const stepHandoff = retainedHistory.at(-1)?.summary ?? "";
6106
+ const reviewedApproval = retainedReviewedApproval(workflow, retainedHistory);
6107
+ return {
6108
+ changed: true,
6109
+ restartedStep,
6110
+ run: withRunUpdate(reconciledRun, {
6111
+ workflowDigest: workflow.digest,
6112
+ status: "paused",
6113
+ currentStepId: restartedStep,
6114
+ currentStepDigest: workflow.stepDigests[restartedStep] ?? "",
6115
+ history: retainedHistory,
6116
+ currentStepAttempts: changedEntry.attempts,
6117
+ currentStepOmittedAttempts: changedEntry.omittedAttempts,
6118
+ visits: rebuildVisits(retainedHistory, restartedStep),
6119
+ cwd: retainedWorkspaceCwd(reconciledRun, retainedHistory),
6120
+ reviewedArtifact: reviewedApproval?.artifact ?? "",
6121
+ reviewedFeedback: reviewedApproval?.feedback ?? "",
6122
+ stepHandoff,
6123
+ lastSummary: stepHandoff,
6124
+ pendingGate: undefined,
6125
+ pausedFrom: "running",
6126
+ failedStepId: undefined,
6127
+ pauseReason: `Configuration changed; restarted step "${restartedStep}"`,
6128
+ gateArtifact: "",
6129
+ gateFeedback: ""
6130
+ }, now)
6131
+ };
6132
+ }
6133
+ const currentDigest = workflow.stepDigests[run.currentStepId] ?? "";
6134
+ const hasCurrentStepChanged = currentDigest !== reconciledRun.currentStepDigest;
6135
+ const restartChanges = hasCurrentStepChanged ? {
6136
+ status: "paused",
6137
+ pendingGate: undefined,
6138
+ pausedFrom: "running",
6139
+ failedStepId: undefined,
6140
+ pauseReason: `Configuration changed; restarted step "${run.currentStepId}"`,
6141
+ gateArtifact: "",
6142
+ gateFeedback: ""
6143
+ } : {};
6026
6144
  return {
6027
- ...advanced,
6028
- history,
6029
- ...completedApprovedGate ? {
6030
- reviewedArtifact: pendingGate.artifact,
6031
- reviewedFeedback: feedback
6032
- } : {},
6033
- gateArtifact: resolution.approved ? "" : pendingGate.artifact,
6034
- gateFeedback: resolution.approved ? "" : feedback
6145
+ changed: true,
6146
+ ...hasCurrentStepChanged ? { restartedStep: run.currentStepId } : {},
6147
+ run: withRunUpdate(reconciledRun, {
6148
+ ...restartChanges,
6149
+ workflowDigest: workflow.digest,
6150
+ currentStepDigest: currentDigest
6151
+ }, now)
6035
6152
  };
6036
6153
  };
6037
- // src/engine/run-lifecycle.ts
6038
- var allowedOutcomes = (workflow, run) => {
6039
- const step = currentStep(workflow, run);
6040
- if (!step)
6041
- return [];
6042
- const gateResolutionOutcomes = step.gate ? new Set([step.gate.approvedOutcome, step.gate.rejectedOutcome]) : undefined;
6043
- return [
6044
- ...Object.keys(step.transitions).filter((outcome) => !gateResolutionOutcomes?.has(outcome)),
6045
- ...step.gate ? [step.gate.submitOutcome] : []
6046
- ];
6047
- };
6048
- var pauseRun = (run, reason, now) => {
6049
- if (run.status !== "running" && run.status !== "awaiting-gate") {
6050
- return withRunUpdate(run, { pauseReason: reason || run.pauseReason }, now);
6154
+ // src/workflow-doctor.ts
6155
+ var lexical = (left, right) => left < right ? -1 : left > right ? 1 : 0;
6156
+ var internalTargets = (step) => [
6157
+ ...new Set(Object.values(step.transitions).filter((target) => target !== "$done" && target !== "$pause"))
6158
+ ].sort(lexical);
6159
+ var adjacencyFor = (definition) => Object.fromEntries(Object.entries(definition.steps).sort(([left], [right]) => lexical(left, right)).map(([stepId, step]) => [stepId, internalTargets(step)]));
6160
+ var reachableSteps = (definition, adjacency) => {
6161
+ const reachable = new Set;
6162
+ const pending = [definition.start];
6163
+ while (pending.length > 0) {
6164
+ const stepId = pending.pop();
6165
+ if (!stepId || reachable.has(stepId))
6166
+ continue;
6167
+ if (!definition.steps[stepId])
6168
+ continue;
6169
+ reachable.add(stepId);
6170
+ pending.push(...adjacency[stepId] ?? []);
6051
6171
  }
6052
- return withRunUpdate(run, {
6053
- status: "paused",
6054
- pausedFrom: run.status,
6055
- pauseReason: reason || `Paused during step "${run.currentStepId}"`,
6056
- failedStepId: undefined
6057
- }, now);
6058
- };
6059
- var failRun = (run, reason, now) => {
6060
- const pausedRun = pauseRun(run, reason, now);
6061
- return pausedRun.status === "paused" ? { ...pausedRun, failedStepId: pausedRun.currentStepId } : pausedRun;
6062
- };
6063
- var resumeRun = (run, now) => {
6064
- if (run.status !== "paused")
6065
- return run;
6066
- return withRunUpdate(run, {
6067
- status: run.pausedFrom ?? (run.pendingGate ? "awaiting-gate" : "running"),
6068
- pauseReason: undefined,
6069
- pausedFrom: undefined,
6070
- failedStepId: undefined
6071
- }, now);
6072
- };
6073
- var setResumeInput = (run, input, now) => withRunUpdate(run, { resumeInput: input.trim() || undefined }, now);
6074
- var abortRun = (run, reason, now) => withRunUpdate(run, {
6075
- status: "aborted",
6076
- pauseReason: reason || "Aborted by user",
6077
- pausedFrom: undefined,
6078
- failedStepId: undefined,
6079
- pendingGate: undefined,
6080
- resumeInput: undefined
6081
- }, now);
6082
- // src/engine/reconciliation-history.ts
6083
- var isApprovedGateEntry = (workflow, entry) => {
6084
- const gate = workflow.definition.steps[entry.stepId]?.gate;
6085
- return gate !== undefined && entry.outcome === gate.approvedOutcome;
6172
+ return reachable;
6086
6173
  };
6087
- var latestApprovedGateEntry = (workflow, history) => {
6088
- for (let index = history.length - 1;index >= 0; index -= 1) {
6089
- const entry = history[index];
6090
- if (entry && isApprovedGateEntry(workflow, entry))
6091
- return { entry, index };
6174
+ var stepsThatCanComplete = (definition, adjacency) => {
6175
+ const canComplete = new Set(Object.entries(definition.steps).filter(([, step]) => Object.values(step.transitions).includes("$done")).map(([stepId]) => stepId));
6176
+ const reverse = new Map;
6177
+ for (const [source, targets] of Object.entries(adjacency)) {
6178
+ for (const target of targets) {
6179
+ reverse.set(target, [...reverse.get(target) ?? [], source]);
6180
+ }
6092
6181
  }
6093
- return;
6094
- };
6095
- var rebuildVisits = (history, currentStepId) => {
6096
- const visitedStepIds = [
6097
- ...history.map((entry) => entry.stepId),
6098
- currentStepId
6099
- ];
6100
- return visitedStepIds.reduce((visits, stepId) => ({
6101
- ...visits,
6102
- [stepId]: (visits[stepId] ?? 0) + 1
6103
- }), {});
6104
- };
6105
- var retainedWorkspaceCwd = (run, history) => {
6106
- for (let index = history.length - 1;index >= 0; index -= 1) {
6107
- const cwd = history[index]?.workspaceCwd;
6108
- if (cwd)
6109
- return cwd;
6182
+ const pending = [...canComplete].sort(lexical);
6183
+ while (pending.length > 0) {
6184
+ const stepId = pending.pop();
6185
+ if (!stepId)
6186
+ continue;
6187
+ for (const predecessor of (reverse.get(stepId) ?? []).sort(lexical)) {
6188
+ if (canComplete.has(predecessor))
6189
+ continue;
6190
+ canComplete.add(predecessor);
6191
+ pending.push(predecessor);
6192
+ }
6110
6193
  }
6111
- return run.startCwd ?? run.cwd;
6112
- };
6113
- var retainedReviewedApproval = (workflow, history) => {
6114
- const retainedApproval = latestApprovedGateEntry(workflow, history);
6115
- const approval = retainedApproval?.entry.approval;
6116
- return approval ? { artifact: approval.artifact, feedback: approval.feedback } : undefined;
6194
+ return canComplete;
6117
6195
  };
6118
- var refreshApprovedGateHistory = (run, workflow) => {
6119
- const history = run.history.map((entry) => {
6120
- const gate = workflow.definition.steps[entry.stepId]?.gate;
6121
- const currentDigest = workflow.stepDigests[entry.stepId];
6122
- const currentStructuralDigest = workflow.stepStructuralDigests[entry.stepId];
6123
- const shouldRefresh = gate !== undefined && typeof currentDigest === "string" && currentDigest.length > 0 && typeof currentStructuralDigest === "string" && currentStructuralDigest.length > 0 && entry.outcome === gate.approvedOutcome && entry.approval?.stepStructuralDigest === currentStructuralDigest && entry.stepDigest !== currentDigest;
6124
- return shouldRefresh ? { ...entry, stepDigest: currentDigest } : entry;
6125
- });
6126
- const hasChanged = history.some((entry, index) => entry !== run.history[index]);
6127
- return hasChanged ? { ...run, history } : run;
6196
+ var stronglyConnectedComponents = (definition, adjacency) => {
6197
+ let nextIndex = 0;
6198
+ const indexes = new Map;
6199
+ const lowLinks = new Map;
6200
+ const stack = [];
6201
+ const onStack = new Set;
6202
+ const components = [];
6203
+ const visit = (stepId) => {
6204
+ indexes.set(stepId, nextIndex);
6205
+ lowLinks.set(stepId, nextIndex);
6206
+ nextIndex += 1;
6207
+ stack.push(stepId);
6208
+ onStack.add(stepId);
6209
+ for (const target of adjacency[stepId] ?? []) {
6210
+ if (!indexes.has(target)) {
6211
+ visit(target);
6212
+ lowLinks.set(stepId, Math.min(lowLinks.get(stepId) ?? 0, lowLinks.get(target) ?? 0));
6213
+ } else if (onStack.has(target)) {
6214
+ lowLinks.set(stepId, Math.min(lowLinks.get(stepId) ?? 0, indexes.get(target) ?? 0));
6215
+ }
6216
+ }
6217
+ if (lowLinks.get(stepId) !== indexes.get(stepId))
6218
+ return;
6219
+ const component = [];
6220
+ let member;
6221
+ do {
6222
+ member = stack.pop();
6223
+ if (!member)
6224
+ break;
6225
+ onStack.delete(member);
6226
+ component.push(member);
6227
+ } while (member !== stepId);
6228
+ components.push(component.sort(lexical));
6229
+ };
6230
+ for (const stepId of Object.keys(definition.steps).sort(lexical)) {
6231
+ if (!indexes.has(stepId))
6232
+ visit(stepId);
6233
+ }
6234
+ return components.sort((left, right) => lexical(left.join("\x00"), right.join("\x00")));
6128
6235
  };
6129
-
6130
- // src/engine/run-workflow-validation.ts
6131
- var sameVisitCounts = (actual, expected) => {
6132
- const actualEntries = Object.entries(actual).sort(([left], [right]) => left.localeCompare(right));
6133
- const expectedEntries = Object.entries(expected).sort(([left], [right]) => left.localeCompare(right));
6134
- return actualEntries.length === expectedEntries.length && actualEntries.every(([stepId, count], index) => expectedEntries[index]?.[0] === stepId && expectedEntries[index][1] === count);
6236
+ var isCycle = (adjacency, component) => {
6237
+ if (component.length > 1)
6238
+ return true;
6239
+ const [stepId] = component;
6240
+ return Boolean(stepId && adjacency[stepId]?.includes(stepId));
6135
6241
  };
6136
- var validateHistoryMetadata = (workflow, step, entry) => {
6137
- const shouldBind = step.workspace?.bindOn.includes(entry.outcome) === true;
6138
- if (shouldBind !== (entry.workspaceCwd !== undefined)) {
6139
- return shouldBind ? `history step "${entry.stepId}" is missing its workspace binding` : `history step "${entry.stepId}" has an unauthorized workspace binding`;
6140
- }
6141
- const isApprovedGate = step.gate !== undefined && entry.outcome === step.gate.approvedOutcome;
6142
- if (isApprovedGate && !entry.approval) {
6143
- return `history step "${entry.stepId}" is missing authoritative gate approval`;
6144
- }
6145
- if (isApprovedGate && entry.artifact !== entry.approval?.artifact) {
6146
- return `history step "${entry.stepId}" approval artifact is inconsistent`;
6242
+ function analyzeWorkflow(definition) {
6243
+ const adjacency = adjacencyFor(definition);
6244
+ const reachable = reachableSteps(definition, adjacency);
6245
+ const canComplete = stepsThatCanComplete(definition, adjacency);
6246
+ const issues = [];
6247
+ const stranded = [...reachable].filter((stepId) => !canComplete.has(stepId)).sort(lexical);
6248
+ if (!canComplete.has(definition.start)) {
6249
+ issues.push({
6250
+ level: "error",
6251
+ code: "no-completion-path",
6252
+ steps: [definition.start],
6253
+ message: `start step ${definition.start} cannot reach $done`
6254
+ });
6147
6255
  }
6148
- if (!isApprovedGate && (entry.approval || entry.artifact !== undefined)) {
6149
- return `history step "${entry.stepId}" has approval data for a non-approved outcome`;
6256
+ if (stranded.length > 0) {
6257
+ issues.push({
6258
+ level: "error",
6259
+ code: "reachable-step-cannot-reach-done",
6260
+ steps: stranded,
6261
+ message: `reachable step${stranded.length === 1 ? "" : "s"} ${stranded.join(", ")} cannot reach $done`
6262
+ });
6150
6263
  }
6151
- if (isApprovedGate && entry.approval?.stepStructuralDigest !== workflow.stepStructuralDigests[entry.stepId]) {
6152
- return `history step "${entry.stepId}" approval does not match its configured structure`;
6264
+ const unreachable = Object.keys(definition.steps).filter((stepId) => !reachable.has(stepId)).sort(lexical);
6265
+ if (unreachable.length > 0) {
6266
+ issues.push({
6267
+ level: "warning",
6268
+ code: "unreachable-steps",
6269
+ steps: unreachable,
6270
+ message: `unreachable step${unreachable.length === 1 ? "" : "s"}: ${unreachable.join(", ")}`
6271
+ });
6153
6272
  }
6154
- return;
6155
- };
6156
- var validatePendingGate = (run, step) => {
6157
- const pending = run.pendingGate;
6158
- if (!pending)
6159
- return;
6160
- if (!step.gate || pending.stepId !== run.currentStepId || pending.provider !== step.gate.provider || pending.submittedOutcome !== step.gate.submitOutcome) {
6161
- return "pending gate does not match the current workflow step";
6273
+ for (const component of stronglyConnectedComponents(definition, adjacency)) {
6274
+ if (!isCycle(adjacency, component))
6275
+ continue;
6276
+ const componentIsReachable = component.some((stepId) => reachable.has(stepId));
6277
+ const componentCanReachDone = component.some((stepId) => canComplete.has(stepId));
6278
+ issues.push({
6279
+ level: "warning",
6280
+ code: "cycle",
6281
+ steps: component,
6282
+ reachable: componentIsReachable,
6283
+ canReachDone: componentCanReachDone,
6284
+ message: `${componentIsReachable ? "reachable" : "unreachable"} cyclic component: ${component.join(", ")}; ${componentCanReachDone ? "an exit can reach $done" : "no member can reach $done"}; maxStepVisits=${definition.maxStepVisits} bounds uninterrupted graph cycling`
6285
+ });
6162
6286
  }
6163
- if (pending.provider === "prompt" && pending.reviewId !== undefined) {
6164
- return "built-in prompt gate cannot carry a Plannotator review id";
6287
+ return {
6288
+ workflowId: definition.id,
6289
+ maxStepVisits: definition.maxStepVisits,
6290
+ reachableSteps: [...reachable].sort(lexical),
6291
+ issues
6292
+ };
6293
+ }
6294
+ var escapeMarkdown = (value) => value.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
6295
+ function formatWorkflowDoctor(reports) {
6296
+ const lines = ["# Workflow doctor", ""];
6297
+ for (const report of reports) {
6298
+ const errors = report.issues.filter((issue) => issue.level === "error");
6299
+ const warnings = report.issues.filter((issue) => issue.level === "warning");
6300
+ lines.push(`## ${report.workflowId}`, "", `Result: ${errors.length > 0 ? "ERROR" : warnings.length > 0 ? "WARNING" : "PASS"}`, "", `Runtime loop guard: automatic graph advancement enters each step at most ${report.maxStepVisits} time${report.maxStepVisits === 1 ? "" : "s"} before the next attempted entry pauses the run. An explicit human rejection back to the same gated step bypasses that check for its transition because every revision awaits another decision; the visit is still recorded. This bounds unattended cycling; it does not guarantee $done or bound time spent inside a step or gate.`, "");
6301
+ if (report.issues.length === 0) {
6302
+ lines.push("- No liveness issues found.", "");
6303
+ continue;
6304
+ }
6305
+ lines.push(...report.issues.map((issue) => `- ${issue.level.toUpperCase()} \`${issue.code}\`: ${escapeMarkdown(issue.message)}`), "");
6165
6306
  }
6166
- return;
6167
- };
6168
- function validateRunWorkflowSemantics(run, workflow) {
6169
- const sameWorkflowDigest = run.workflowDigest === workflow.digest;
6170
- let expectedStepId = workflow.definition.start;
6171
- let reachedDone = false;
6172
- let boundWorkspaceCwd;
6173
- let latestApproval;
6174
- const expectedVisits = { [expectedStepId]: 1 };
6175
- for (let index = 0;index < run.history.length; index += 1) {
6307
+ return lines.join(`
6308
+ `).trimEnd();
6309
+ }
6310
+
6311
+ // src/workflow-list.ts
6312
+ function escapeMarkdownTableCell(value) {
6313
+ return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replace(/\r\n|\r|\n/g, " ");
6314
+ }
6315
+ function formatWorkflowList(workflows) {
6316
+ return [
6317
+ "| Workflow | Command | Description |",
6318
+ "| --- | --- | --- |",
6319
+ ...workflows.map((workflow) => `| \`${workflow.id}\` | \`/${workflow.command}\` | ${escapeMarkdownTableCell(workflow.description)} |`)
6320
+ ].join(`
6321
+ `);
6322
+ }
6323
+
6324
+ // src/harness/start-actions.ts
6325
+ function isCurrentSession(session, sessionEpoch) {
6326
+ return session.isSessionActive && session.sessionEpoch === sessionEpoch;
6327
+ }
6328
+ function completedWorkspaceBinding(run, workflow) {
6329
+ for (let index = run.history.length - 1;index >= 0; index -= 1) {
6176
6330
  const entry = run.history[index];
6177
- if (!entry)
6331
+ if (!entry?.workspaceCwd)
6178
6332
  continue;
6179
- if (reachedDone) {
6180
- return "workflow history continues after a $done transition";
6181
- }
6182
- if (entry.stepId !== expectedStepId) {
6183
- return `history step "${entry.stepId}" is not reachable after "${expectedStepId}"`;
6184
- }
6185
6333
  const step = workflow.definition.steps[entry.stepId];
6186
- const configuredDigest = workflow.stepDigests[entry.stepId];
6187
- if (!step || entry.stepDigest !== configuredDigest) {
6188
- return sameWorkflowDigest ? `history step "${entry.stepId}" does not match the active workflow digest` : undefined;
6189
- }
6190
- const metadataError = validateHistoryMetadata(workflow, step, entry);
6191
- if (metadataError)
6192
- return metadataError;
6193
- if (entry.workspaceCwd) {
6194
- if (boundWorkspaceCwd !== undefined && boundWorkspaceCwd !== entry.workspaceCwd) {
6195
- return "workflow history attempts to replace an existing workspace binding";
6196
- }
6197
- boundWorkspaceCwd = entry.workspaceCwd;
6198
- }
6199
- if (entry.approval) {
6200
- latestApproval = {
6201
- artifact: entry.approval.artifact,
6202
- feedback: entry.approval.feedback
6203
- };
6204
- }
6205
- const target = step.transitions[entry.outcome];
6206
- if (!target) {
6207
- return `history outcome "${entry.outcome}" is not configured for step "${entry.stepId}"`;
6208
- }
6209
- if (target === "$pause") {
6210
- return `history step "${entry.stepId}" records a non-completing $pause transition`;
6211
- }
6212
- if (target === "$done") {
6213
- if (index !== run.history.length - 1) {
6214
- return "workflow history continues after a $done transition";
6215
- }
6216
- reachedDone = true;
6217
- continue;
6334
+ if (!step?.workspace || !step.workspace.bindOn.includes(entry.outcome)) {
6335
+ throw new Error(`workspace-binding step "${entry.stepId}" no longer matches the completed iteration`);
6218
6336
  }
6219
- expectedStepId = target;
6220
- expectedVisits[target] = (expectedVisits[target] ?? 0) + 1;
6337
+ return {
6338
+ cwd: entry.workspaceCwd,
6339
+ allowedRoots: step.workspace.allowedRoots
6340
+ };
6341
+ }
6342
+ return;
6343
+ }
6344
+ async function listWorkflows(context) {
6345
+ const workflows = [...this.catalog.workflows.values()].sort((left, right) => left.definition.id.localeCompare(right.definition.id));
6346
+ if (workflows.length === 0) {
6347
+ context.ui.notify(`No workflows loaded from ${this.catalog.userDirectory}`, this.catalog.diagnostics.length > 0 ? "warning" : "info");
6348
+ return;
6349
+ }
6350
+ this.pi.sendMessage({
6351
+ customType: "workflow-list",
6352
+ content: formatWorkflowList(workflows.map((workflow) => workflow.definition)),
6353
+ display: true
6354
+ });
6355
+ }
6356
+ async function doctorWorkflows(workflowId, context) {
6357
+ const catalog = await this.dependencies.loadCatalog({
6358
+ cwd: context.cwd,
6359
+ projectTrusted: context.isProjectTrusted()
6360
+ });
6361
+ if (catalog.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
6362
+ context.ui.notify(`Workflow configuration errors:
6363
+ ${formatCatalogDiagnostics(catalog)}`, "warning");
6221
6364
  }
6222
- if (run.currentStepId !== expectedStepId) {
6223
- return `current step "${run.currentStepId}" does not match reachable step "${expectedStepId}"`;
6365
+ const selected = workflowId ? [catalog.workflows.get(workflowId)].filter((workflow) => workflow !== undefined) : [...catalog.workflows.values()].sort((left, right) => left.definition.id.localeCompare(right.definition.id));
6366
+ if (workflowId && selected.length === 0) {
6367
+ context.ui.notify(`Workflow "${workflowId}" is not loaded`, "error");
6368
+ return;
6224
6369
  }
6225
- if (reachedDone !== (run.status === "completed")) {
6226
- return reachedDone ? "a workflow that reached $done must be completed" : "a completed workflow has no $done transition in its history";
6370
+ if (selected.length === 0) {
6371
+ context.ui.notify(`No workflows loaded from ${catalog.userDirectory}`, catalog.diagnostics.length > 0 ? "warning" : "info");
6372
+ return;
6227
6373
  }
6228
- if (run.status === "completed" && run.currentStepDigest !== run.history.at(-1)?.stepDigest) {
6229
- return "completed workflow current-step digest does not match its terminal history";
6374
+ this.pi.sendMessage({
6375
+ customType: "workflow-doctor",
6376
+ content: formatWorkflowDoctor(selected.map((workflow) => analyzeWorkflow(workflow.definition))),
6377
+ display: true
6378
+ });
6379
+ }
6380
+ async function startNow(workflowId, input, startContext, sessionEpoch) {
6381
+ const { context } = startContext;
6382
+ if (this.activeDelegation) {
6383
+ context.ui.notify(`Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`, "warning");
6384
+ return;
6230
6385
  }
6231
- if (!sameVisitCounts(run.visits, expectedVisits)) {
6232
- return "workflow visit counts do not match its execution history";
6386
+ if (this.run && this.run.status !== "completed" && this.run.status !== "aborted") {
6387
+ context.ui.notify(`Workflow "${this.run.workflowId}" is ${this.run.status}; resume or abort it first`, "warning");
6388
+ return;
6233
6389
  }
6234
- const reviewedArtifact = run.reviewedArtifact ?? "";
6235
- const reviewedFeedback = run.reviewedFeedback ?? "";
6236
- if (reviewedArtifact !== (latestApproval?.artifact ?? "") || reviewedFeedback !== (latestApproval?.feedback ?? "")) {
6237
- return "reviewed artifact and feedback do not match authoritative approval history";
6390
+ if (!context.isIdle()) {
6391
+ context.abort();
6392
+ await startContext.waitForIdle();
6238
6393
  }
6239
- const currentStep2 = workflow.definition.steps[run.currentStepId];
6240
- if (!currentStep2) {
6241
- return `current step "${run.currentStepId}" is missing from the workflow`;
6394
+ if (!isCurrentSession(this, sessionEpoch)) {
6395
+ context.ui.notify("Workflow start was superseded by a session change", "warning");
6396
+ return;
6242
6397
  }
6243
- const currentStepChanged = run.currentStepDigest !== workflow.stepDigests[run.currentStepId];
6244
- if (sameWorkflowDigest && currentStepChanged) {
6245
- return `current step "${run.currentStepId}" does not match the active workflow digest`;
6398
+ this.captureSkills(startContext.skills());
6399
+ if (!await this.reloadCatalog(context, false)) {
6400
+ context.ui.notify("Workflow start was superseded by a newer configuration load", "warning");
6401
+ return;
6246
6402
  }
6247
- if (boundWorkspaceCwd && !currentStep2.subagent) {
6248
- return `bound workflow current step "${run.currentStepId}" must use a subagent`;
6403
+ if (!isCurrentSession(this, sessionEpoch)) {
6404
+ context.ui.notify("Workflow start was superseded by a session change", "warning");
6405
+ return;
6249
6406
  }
6250
- if (currentStepChanged)
6407
+ const workflow = this.catalog.workflows.get(workflowId);
6408
+ if (!workflow) {
6409
+ context.ui.notify(`Workflow "${workflowId}" is not loaded`, "error");
6251
6410
  return;
6252
- return validatePendingGate(run, currentStep2);
6411
+ }
6412
+ const livenessErrors = analyzeWorkflow(workflow.definition).issues.filter((issue) => issue.level === "error");
6413
+ if (livenessErrors.length > 0) {
6414
+ context.ui.notify(`Cannot start workflow; run /workflow-doctor ${workflowId}:
6415
+ ${livenessErrors.map((issue) => issue.message).join(`
6416
+ `)}`, "error");
6417
+ return;
6418
+ }
6419
+ const preflightErrors = this.preflight(workflow, workflow.definition.start);
6420
+ if (preflightErrors.length > 0) {
6421
+ context.ui.notify(`Cannot start workflow:
6422
+ ${preflightErrors.join(`
6423
+ `)}`, "error");
6424
+ return;
6425
+ }
6426
+ let canonicalStartCwd;
6427
+ try {
6428
+ canonicalStartCwd = this.dependencies.resolveWorkspaceDirectory({
6429
+ candidateCwd: context.cwd,
6430
+ startCwd: context.cwd,
6431
+ allowedRoots: ["."]
6432
+ });
6433
+ } catch (error) {
6434
+ context.ui.notify(`Cannot capture workflow working directory: ${error instanceof Error ? error.message : String(error)}`, "error");
6435
+ return;
6436
+ }
6437
+ this.run = createRun(workflow, input.trim(), this.pi.getActiveTools(), this.dependencies.createRequestId(), this.dependencies.now(), canonicalStartCwd);
6438
+ this.persist();
6439
+ this.isolateMainSessionTools();
6440
+ this.updateStatus();
6441
+ this.launchCurrentStep(workflow);
6253
6442
  }
6254
-
6255
- // src/engine/run-reconciliation.ts
6256
- var reconcileRun = (run, workflow, now) => {
6257
- if (run.workflowId !== workflow.definition.id) {
6258
- return {
6259
- changed: false,
6260
- error: `run belongs to "${run.workflowId}", not "${workflow.definition.id}"`
6261
- };
6443
+ async function restartNow(input, startContext, sessionEpoch) {
6444
+ const { context } = startContext;
6445
+ const completedRun = this.run;
6446
+ if (!completedRun || completedRun.status !== "completed") {
6447
+ context.ui.notify("Only a completed workflow can be restarted", "warning");
6448
+ return;
6262
6449
  }
6263
- const reconciledRun = run.workflowDigest === workflow.digest ? run : refreshApprovedGateHistory(run, workflow);
6264
- const changedHistoryIndex = run.workflowDigest === workflow.digest ? -1 : reconciledRun.history.findIndex((entry) => workflow.stepDigests[entry.stepId] !== entry.stepDigest);
6265
- const changedHistoryEntry = changedHistoryIndex >= 0 ? reconciledRun.history[changedHistoryIndex] : undefined;
6266
- if (changedHistoryEntry && !workflow.definition.steps[changedHistoryEntry.stepId]) {
6267
- return {
6268
- changed: true,
6269
- error: "a completed step was removed; abort or restore the configuration"
6270
- };
6450
+ if (this.activeDelegation) {
6451
+ context.ui.notify(`Cannot restart while subagent "${this.activeDelegation.agent}" is still cancelling`, "warning");
6452
+ return;
6271
6453
  }
6272
- if (changedHistoryIndex < 0 && !workflow.definition.steps[run.currentStepId]) {
6273
- return {
6274
- changed: true,
6275
- error: `current step "${run.currentStepId}" was removed; abort or restore the configuration`
6276
- };
6454
+ if (!completedRun.startCwd) {
6455
+ context.ui.notify("Cannot restart this workflow on the same worktree because its original start directory was not captured; start a new workflow instead", "error");
6456
+ return;
6277
6457
  }
6278
- const semanticError = validateRunWorkflowSemantics(reconciledRun, workflow);
6279
- if (semanticError) {
6280
- return {
6281
- changed: run.workflowDigest !== workflow.digest,
6282
- error: `workflow checkpoint is inconsistent: ${semanticError}`
6283
- };
6458
+ if (!context.isIdle()) {
6459
+ context.abort();
6460
+ await startContext.waitForIdle();
6284
6461
  }
6285
- if (run.workflowDigest === workflow.digest) {
6286
- return { run, changed: false };
6462
+ if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
6463
+ context.ui.notify("Workflow restart was superseded by a session or workflow change", "warning");
6464
+ return;
6287
6465
  }
6288
- if (changedHistoryIndex >= 0) {
6289
- const changedEntry = changedHistoryEntry;
6290
- if (!changedEntry) {
6291
- return { changed: true, error: "changed history entry is unavailable" };
6466
+ this.captureSkills(startContext.skills());
6467
+ if (!await this.reloadCatalog(context, false)) {
6468
+ context.ui.notify("Workflow restart was superseded by a newer configuration load", "warning");
6469
+ return;
6470
+ }
6471
+ if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
6472
+ context.ui.notify("Workflow restart was superseded by a session or workflow change", "warning");
6473
+ return;
6474
+ }
6475
+ const workflow = this.catalog.workflows.get(completedRun.workflowId);
6476
+ if (!workflow) {
6477
+ context.ui.notify(`Workflow "${completedRun.workflowId}" is no longer loaded`, "error");
6478
+ return;
6479
+ }
6480
+ const livenessErrors = analyzeWorkflow(workflow.definition).issues.filter((issue) => issue.level === "error");
6481
+ if (livenessErrors.length > 0) {
6482
+ context.ui.notify(`Cannot restart workflow; run /workflow-doctor ${workflow.definition.id}:
6483
+ ${livenessErrors.map((issue) => issue.message).join(`
6484
+ `)}`, "error");
6485
+ return;
6486
+ }
6487
+ const preflightErrors = this.preflight(workflow, workflow.definition.start);
6488
+ if (preflightErrors.length > 0) {
6489
+ context.ui.notify(`Cannot restart workflow:
6490
+ ${preflightErrors.join(`
6491
+ `)}`, "error");
6492
+ return;
6493
+ }
6494
+ let canonicalStartCwd;
6495
+ let canonicalSessionCwd;
6496
+ try {
6497
+ canonicalStartCwd = this.dependencies.resolveWorkspaceDirectory({
6498
+ candidateCwd: completedRun.startCwd,
6499
+ startCwd: completedRun.startCwd,
6500
+ allowedRoots: ["."]
6501
+ });
6502
+ canonicalSessionCwd = this.dependencies.resolveWorkspaceDirectory({
6503
+ candidateCwd: context.cwd,
6504
+ startCwd: context.cwd,
6505
+ allowedRoots: ["."]
6506
+ });
6507
+ } catch (error) {
6508
+ context.ui.notify(`Cannot restart workflow on its captured worktree: ${error instanceof Error ? error.message : String(error)}`, "error");
6509
+ return;
6510
+ }
6511
+ if (canonicalStartCwd !== completedRun.startCwd || canonicalSessionCwd !== canonicalStartCwd) {
6512
+ context.ui.notify("Current session cwd does not match the captured workflow start directory", "error");
6513
+ return;
6514
+ }
6515
+ try {
6516
+ const binding = completedWorkspaceBinding(completedRun, workflow);
6517
+ if (binding) {
6518
+ const canonicalWorkspaceCwd = this.dependencies.resolveWorkspaceDirectory({
6519
+ candidateCwd: binding.cwd,
6520
+ startCwd: canonicalStartCwd,
6521
+ allowedRoots: binding.allowedRoots
6522
+ });
6523
+ if (canonicalWorkspaceCwd !== binding.cwd) {
6524
+ throw new Error("previous workspace no longer resolves to its captured canonical directory");
6525
+ }
6292
6526
  }
6293
- const retainedHistory = reconciledRun.history.slice(0, changedHistoryIndex);
6294
- const restartedStep = changedEntry.stepId;
6295
- const stepHandoff = retainedHistory.at(-1)?.summary ?? "";
6296
- const reviewedApproval = retainedReviewedApproval(workflow, retainedHistory);
6297
- return {
6298
- changed: true,
6299
- restartedStep,
6300
- run: withRunUpdate(reconciledRun, {
6301
- workflowDigest: workflow.digest,
6302
- status: "paused",
6303
- currentStepId: restartedStep,
6304
- currentStepDigest: workflow.stepDigests[restartedStep] ?? "",
6305
- history: retainedHistory,
6306
- currentStepAttempts: changedEntry.attempts,
6307
- currentStepOmittedAttempts: changedEntry.omittedAttempts,
6308
- visits: rebuildVisits(retainedHistory, restartedStep),
6309
- cwd: retainedWorkspaceCwd(reconciledRun, retainedHistory),
6310
- reviewedArtifact: reviewedApproval?.artifact ?? "",
6311
- reviewedFeedback: reviewedApproval?.feedback ?? "",
6312
- stepHandoff,
6313
- lastSummary: stepHandoff,
6314
- pendingGate: undefined,
6315
- pausedFrom: "running",
6316
- failedStepId: undefined,
6317
- pauseReason: `Configuration changed; restarted step "${restartedStep}"`,
6318
- gateArtifact: "",
6319
- gateFeedback: ""
6320
- }, now)
6321
- };
6527
+ this.run = restartRun(workflow, completedRun, input.trim() || completedRun.input, this.pi.getActiveTools(), this.dependencies.now());
6528
+ } catch (error) {
6529
+ context.ui.notify(`Cannot restart workflow on the same worktree: ${error instanceof Error ? error.message : String(error)}`, "error");
6530
+ return;
6322
6531
  }
6323
- const currentDigest = workflow.stepDigests[run.currentStepId] ?? "";
6324
- const hasCurrentStepChanged = currentDigest !== reconciledRun.currentStepDigest;
6325
- const restartChanges = hasCurrentStepChanged ? {
6326
- status: "paused",
6327
- pendingGate: undefined,
6328
- pausedFrom: "running",
6329
- failedStepId: undefined,
6330
- pauseReason: `Configuration changed; restarted step "${run.currentStepId}"`,
6331
- gateArtifact: "",
6332
- gateFeedback: ""
6333
- } : {};
6334
- return {
6335
- changed: true,
6336
- ...hasCurrentStepChanged ? { restartedStep: run.currentStepId } : {},
6337
- run: withRunUpdate(reconciledRun, {
6338
- ...restartChanges,
6339
- workflowDigest: workflow.digest,
6340
- currentStepDigest: currentDigest
6341
- }, now)
6342
- };
6343
- };
6532
+ this.persist();
6533
+ this.isolateMainSessionTools();
6534
+ this.updateStatus();
6535
+ this.launchCurrentStep(workflow);
6536
+ }
6537
+ async function reloadNow(context) {
6538
+ if (this.run && (this.run.status === "running" || this.run.status === "awaiting-gate")) {
6539
+ context.ui.notify("Pause the workflow before reloading its configuration", "warning");
6540
+ return;
6541
+ }
6542
+ this.captureSkills(context.getSystemPromptOptions().skills);
6543
+ await this.reloadCatalog(context, true);
6544
+ }
6545
+ function createStartActions() {
6546
+ return { listWorkflows, doctorWorkflows, startNow, restartNow, reloadNow };
6547
+ }
6548
+
6344
6549
  // src/harness/step-reporting.ts
6345
6550
  var WORKFLOW_STEP_SUMMARY_MESSAGE_TYPE = "workflow-step-summary";
6346
6551
  var MAX_POSTED_STEP_SUMMARY_CHARS = 4000;
@@ -6777,6 +6982,17 @@ function buildDelegatedHandoffSection(handoff) {
6777
6982
  ""
6778
6983
  ];
6779
6984
  }
6985
+ function buildRestartWorkspaceSection(workspaceCwd) {
6986
+ if (!workspaceCwd)
6987
+ return [];
6988
+ return [
6989
+ "## Restart workspace constraint",
6990
+ "",
6991
+ `This iteration must reuse and rebind exactly this existing workspace: ${workspaceCwd}`,
6992
+ "Do not create or substitute another workspace. If it cannot be safely reused, complete with a configured non-binding outcome that pauses the workflow.",
6993
+ ""
6994
+ ];
6995
+ }
6780
6996
  function buildDelegatedCompletionInstructions() {
6781
6997
  return [
6782
6998
  "This child is non-interactive. Never call `contact_supervisor`, `subagent_supervisor`, or `intercom`.",
@@ -6817,6 +7033,7 @@ function createTemplateValues({
6817
7033
  return {
6818
7034
  "workflow.input": run.input,
6819
7035
  "workflow.id": workflow.definition.id,
7036
+ "workflow.iteration": String(run.iteration ?? 1),
6820
7037
  "run.id": run.runId,
6821
7038
  "step.id": run.currentStepId,
6822
7039
  "step.title": step.title,
@@ -6825,7 +7042,8 @@ function createTemplateValues({
6825
7042
  "reviewed.feedback": run.reviewedFeedback ?? "",
6826
7043
  "gate.artifact": run.gateArtifact ?? "",
6827
7044
  "gate.feedback": run.gateFeedback,
6828
- "resume.input": run.resumeInput ?? ""
7045
+ "resume.input": run.resumeInput ?? "",
7046
+ "restart.workspace": run.restartWorkspaceCwd ?? ""
6829
7047
  };
6830
7048
  }
6831
7049
 
@@ -6889,6 +7107,7 @@ function buildStepTask(options) {
6889
7107
  "",
6890
7108
  `Workflow: ${workflow.definition.id}`,
6891
7109
  `Run: ${run.runId}`,
7110
+ `Iteration: ${run.iteration ?? 1}`,
6892
7111
  `Step: ${run.currentStepId} (${step.title})`,
6893
7112
  ...isDelegated ? [
6894
7113
  `Agent profile: ${step.subagent?.agent ?? "generalist"}`,
@@ -6900,6 +7119,7 @@ function buildStepTask(options) {
6900
7119
  prompt,
6901
7120
  "",
6902
7121
  ...isDelegated ? buildDelegatedHandoffSection(handoff) : [],
7122
+ ...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
6903
7123
  ...buildResumeInputSection(run, RESUME_INPUT_PLACEHOLDER.test(promptTemplate)),
6904
7124
  ...buildResourceSection({ execution, step }),
6905
7125
  "## Completion contract",
@@ -6998,9 +7218,17 @@ function registerLifecycle() {
6998
7218
  this.restoreFromSession(context);
6999
7219
  this.isSessionActive = true;
7000
7220
  });
7001
- this.pi.on("session_shutdown", async () => {
7221
+ this.pi.on("session_shutdown", async (_event, context) => {
7002
7222
  this.sessionEpoch += 1;
7003
7223
  this.isSessionActive = false;
7224
+ if (this.run) {
7225
+ this.latestContext = context;
7226
+ try {
7227
+ this.persist();
7228
+ } catch (error) {
7229
+ context.ui.notify(`Workflow checkpoint could not be saved before shutdown: ${error instanceof Error ? error.message : String(error)}`, "error");
7230
+ }
7231
+ }
7004
7232
  this.cancelPromptReview();
7005
7233
  this.mainSteps.deactivate();
7006
7234
  await this.cancelActiveDelegation("Pi session shut down");
@@ -8097,6 +8325,9 @@ function enqueueMutation(context, operation) {
8097
8325
  function persist() {
8098
8326
  if (this.run) {
8099
8327
  this.pi.appendEntry(STATE_ENTRY_TYPE, structuredClone(this.run));
8328
+ const session = this.latestContext?.sessionManager;
8329
+ if (session)
8330
+ this.dependencies.flushUnwrittenSession(session);
8100
8331
  }
8101
8332
  }
8102
8333
  function restoreFromSession(context) {
@@ -8231,6 +8462,7 @@ class WorkflowHarness {
8231
8462
  listWorkflows = START_ACTIONS.listWorkflows;
8232
8463
  doctorWorkflows = START_ACTIONS.doctorWorkflows;
8233
8464
  startNow = START_ACTIONS.startNow;
8465
+ restartNow = START_ACTIONS.restartNow;
8234
8466
  reloadNow = START_ACTIONS.reloadNow;
8235
8467
  pauseNow = PAUSE_ACTIONS.pauseNow;
8236
8468
  abortNow = PAUSE_ACTIONS.abortNow;
@@ -8305,6 +8537,13 @@ class WorkflowHarness {
8305
8537
  waitForIdle: () => context.waitForIdle()
8306
8538
  }, sessionEpoch));
8307
8539
  }
8540
+ restart(input, context) {
8541
+ return this.enqueueMutation(context, (sessionEpoch) => this.restartNow(input, {
8542
+ context,
8543
+ skills: () => context.getSystemPromptOptions().skills,
8544
+ waitForIdle: () => context.waitForIdle()
8545
+ }, sessionEpoch));
8546
+ }
8308
8547
  pause(reason, context) {
8309
8548
  return this.enqueueMutation(context, () => this.pauseNow(reason, context));
8310
8549
  }
@@ -8360,14 +8599,14 @@ var parseChildStructuredResult = ({
8360
8599
  // src/integrations/subagents/child-runtime-dependencies.ts
8361
8600
  import { randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
8362
8601
  import {
8363
- existsSync,
8602
+ existsSync as existsSync2,
8364
8603
  lstatSync,
8365
8604
  readFileSync,
8366
8605
  realpathSync as realpathSync2,
8367
8606
  renameSync,
8368
8607
  statSync as statSync2,
8369
8608
  unlinkSync,
8370
- writeFileSync as writeFileSync2
8609
+ writeFileSync as writeFileSync3
8371
8610
  } from "node:fs";
8372
8611
  import { tmpdir as tmpdir3 } from "node:os";
8373
8612
  var tokensAreEqual = (actual, expected) => {
@@ -8377,7 +8616,7 @@ var tokensAreEqual = (actual, expected) => {
8377
8616
  };
8378
8617
  var DEFAULT_CHILD_RUNTIME_DEPENDENCIES = {
8379
8618
  fileSystem: {
8380
- exists: existsSync,
8619
+ exists: existsSync2,
8381
8620
  inspect: lstatSync,
8382
8621
  readText: (path) => readFileSync(path, "utf8"),
8383
8622
  realPath: realpathSync2,
@@ -8385,7 +8624,7 @@ var DEFAULT_CHILD_RUNTIME_DEPENDENCIES = {
8385
8624
  stat: statSync2,
8386
8625
  unlink: unlinkSync,
8387
8626
  writeExclusive: (path, content) => {
8388
- writeFileSync2(path, content, {
8627
+ writeFileSync3(path, content, {
8389
8628
  encoding: "utf8",
8390
8629
  flag: "wx",
8391
8630
  mode: 384