@wichayutdew/pi-workflows 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +19 -955
  2. package/dist/index.js +841 -535
  3. package/examples/mr-comments.workflow.yaml +1 -1
  4. package/examples/prompts/mr-comments/plan.md +8 -1
  5. package/examples/starter-kit/mr-comment.workflow.yaml +1 -1
  6. package/examples/starter-kit/mr-review.workflow.yaml +4 -2
  7. package/examples/starter-kit/steps/mr-comment/plan.md +9 -2
  8. package/examples/starter-kit/steps/mr-review/publish.md +9 -2
  9. package/examples/starter-kit/steps/mr-review/review.md +9 -2
  10. package/examples/starter-kit/steps/mr-review/verify.md +16 -5
  11. package/examples/starter-kit/steps/shared/prepare-workspace.md +42 -4
  12. package/examples/starter-kit/steps/ticket/plan.md +31 -2
  13. package/examples/starter-kit/steps/work/plan.md +31 -2
  14. package/examples/starter-kit/ticket.workflow.yaml +14 -2
  15. package/examples/starter-kit/work.workflow.yaml +14 -2
  16. package/package.json +1 -1
  17. package/schemas/workflow.schema.json +1 -0
  18. package/src/command-names.ts +1 -0
  19. package/src/commands.ts +14 -0
  20. package/src/config/types.ts +1 -1
  21. package/src/config/validation/prompt.ts +3 -0
  22. package/src/engine/create-run.ts +4 -0
  23. package/src/engine/gate-transitions.ts +80 -33
  24. package/src/engine/run-advance.ts +36 -3
  25. package/src/engine/run-lifecycle.ts +69 -0
  26. package/src/engine/run-reconciliation.ts +2 -0
  27. package/src/engine/run-validation.ts +24 -1
  28. package/src/engine/state-types.ts +10 -0
  29. package/src/engine/state.ts +1 -0
  30. package/src/engine/transitions.ts +1 -0
  31. package/src/harness/action-context.ts +6 -0
  32. package/src/harness/core-actions.ts +2 -0
  33. package/src/harness/dependencies.ts +4 -0
  34. package/src/harness/lifecycle-actions.ts +14 -1
  35. package/src/harness/session-persistence.ts +66 -0
  36. package/src/harness/start-actions.ts +188 -1
  37. package/src/harness.ts +20 -0
  38. package/src/prompt/step-sections.ts +14 -0
  39. package/src/prompt/step-task.ts +3 -0
  40. package/src/prompt/template.ts +6 -3
  41. package/src/workflow-doctor.ts +1 -1
  42. package/src/workflow-status/render-summary.ts +3 -0
  43. package/src/workflow-status/view.ts +29 -3
@@ -1,9 +1,34 @@
1
1
  import type { LoadedWorkflow } from '../config/types.ts';
2
2
  import { advanceRun } from './run-advance.ts';
3
3
  import { recordCurrentGateDecision } from './step-trace.ts';
4
- import type { GateResolution, WorkflowRun } from './state-types.ts';
4
+ import {
5
+ MAX_GATE_FEEDBACK_CHARS,
6
+ type GateResolution,
7
+ type WorkflowRun,
8
+ } from './state-types.ts';
5
9
  import { currentStep, withRunUpdate } from './transition-helpers.ts';
6
10
 
11
+ const GATE_FEEDBACK_TRUNCATION_SUFFIX =
12
+ '\n… [gate feedback truncated by Pi Workflows]';
13
+ const MAX_GATE_REJECTION_SUMMARY_CHARS = 500;
14
+
15
+ const boundedGateFeedback = (feedback: string): string =>
16
+ feedback.length <= MAX_GATE_FEEDBACK_CHARS
17
+ ? feedback
18
+ : `${feedback.slice(
19
+ 0,
20
+ MAX_GATE_FEEDBACK_CHARS - GATE_FEEDBACK_TRUNCATION_SUFFIX.length,
21
+ )}${GATE_FEEDBACK_TRUNCATION_SUFFIX}`;
22
+
23
+ const gateRejectionSummary = (feedback: string): string => {
24
+ const compact = feedback.trim().replace(/\s+/g, ' ');
25
+ if (!compact) return 'Gate rejected';
26
+ const summary = `Gate rejected: ${compact}`;
27
+ return summary.length <= MAX_GATE_REJECTION_SUMMARY_CHARS
28
+ ? summary
29
+ : `${summary.slice(0, MAX_GATE_REJECTION_SUMMARY_CHARS - 1)}…`;
30
+ };
31
+
7
32
  /**
8
33
  * Begins human review for a gated workflow step.
9
34
  *
@@ -101,21 +126,22 @@ export const failGate = (
101
126
  run: WorkflowRun,
102
127
  reason: string,
103
128
  now: number,
104
- ): WorkflowRun =>
105
- run.pendingGate
106
- ? withRunUpdate(
107
- run,
108
- {
109
- status: 'running',
110
- pendingGate: undefined,
111
- gateFeedback: reason,
112
- pausedFrom: undefined,
113
- pauseReason: undefined,
114
- failedStepId: undefined,
115
- },
116
- now,
117
- )
118
- : run;
129
+ ): WorkflowRun => {
130
+ if (!run.pendingGate) return run;
131
+ return withRunUpdate(
132
+ run,
133
+ {
134
+ status: 'running',
135
+ pendingGate: undefined,
136
+ gateArtifact: run.pendingGate.artifact,
137
+ gateFeedback: boundedGateFeedback(reason),
138
+ pausedFrom: undefined,
139
+ pauseReason: undefined,
140
+ failedStepId: undefined,
141
+ },
142
+ now,
143
+ );
144
+ };
119
145
 
120
146
  /**
121
147
  * Stores a gate resolution without advancing the workflow.
@@ -129,14 +155,22 @@ export const storeGateResolution = (
129
155
  run: WorkflowRun,
130
156
  resolution: GateResolution,
131
157
  now: number,
132
- ): WorkflowRun =>
133
- run.pendingGate
134
- ? withRunUpdate(
135
- run,
136
- { pendingGate: { ...run.pendingGate, resolution } },
137
- now,
138
- )
139
- : run;
158
+ ): WorkflowRun => {
159
+ if (!run.pendingGate) return run;
160
+ return withRunUpdate(
161
+ run,
162
+ {
163
+ pendingGate: {
164
+ ...run.pendingGate,
165
+ resolution: {
166
+ ...resolution,
167
+ feedback: boundedGateFeedback(resolution.feedback),
168
+ },
169
+ },
170
+ },
171
+ now,
172
+ );
173
+ };
140
174
 
141
175
  /**
142
176
  * Applies a human gate decision and follows its configured transition.
@@ -168,6 +202,7 @@ export const resolveGate = (
168
202
  const outcome = resolution.approved
169
203
  ? step.gate.approvedOutcome
170
204
  : step.gate.rejectedOutcome;
205
+ const feedback = boundedGateFeedback(resolution.feedback);
171
206
  const stepStructuralDigest =
172
207
  workflow.stepStructuralDigests[pendingGate.stepId] ?? '';
173
208
  if (resolution.approved && !stepStructuralDigest) {
@@ -177,16 +212,14 @@ export const resolveGate = (
177
212
  }
178
213
  const summary = resolution.approved
179
214
  ? (pendingGate.summary ?? '')
180
- : resolution.feedback
181
- ? `Gate rejected: ${resolution.feedback}`
182
- : 'Gate rejected';
215
+ : gateRejectionSummary(feedback);
183
216
  const decidedRun = recordCurrentGateDecision(
184
217
  run,
185
218
  {
186
219
  provider: pendingGate.provider,
187
220
  requestId: pendingGate.requestId,
188
221
  approved: resolution.approved,
189
- feedback: resolution.feedback,
222
+ feedback,
190
223
  resolvedAt: resolution.resolvedAt,
191
224
  ...(pendingGate.reviewId ? { reviewId: pendingGate.reviewId } : {}),
192
225
  },
@@ -199,11 +232,24 @@ export const resolveGate = (
199
232
  pendingGate: undefined,
200
233
  pausedFrom: undefined,
201
234
  pauseReason: undefined,
202
- gateFeedback: resolution.feedback,
235
+ gateArtifact: resolution.approved ? '' : pendingGate.artifact,
236
+ gateFeedback: resolution.approved ? '' : feedback,
203
237
  },
204
238
  now,
205
239
  );
206
- const advanced = advanceRun(workflow, runnableRun, outcome, summary, now);
240
+ const isSameStepHumanRevision =
241
+ !resolution.approved && step.transitions[outcome] === pendingGate.stepId;
242
+ const advanced = advanceRun(
243
+ workflow,
244
+ runnableRun,
245
+ outcome,
246
+ summary,
247
+ now,
248
+ {},
249
+ {
250
+ sameStepHumanGateRevision: isSameStepHumanRevision,
251
+ },
252
+ );
207
253
  const completedApprovedGate =
208
254
  resolution.approved && advanced.history.length > runnableRun.history.length;
209
255
  const history = completedApprovedGate
@@ -215,7 +261,7 @@ export const resolveGate = (
215
261
  approval: {
216
262
  requestId: pendingGate.requestId,
217
263
  artifact: pendingGate.artifact,
218
- feedback: resolution.feedback,
264
+ feedback,
219
265
  stepStructuralDigest,
220
266
  },
221
267
  }
@@ -228,9 +274,10 @@ export const resolveGate = (
228
274
  ...(completedApprovedGate
229
275
  ? {
230
276
  reviewedArtifact: pendingGate.artifact,
231
- reviewedFeedback: resolution.feedback,
277
+ reviewedFeedback: feedback,
232
278
  }
233
279
  : {}),
234
- gateFeedback: resolution.feedback,
280
+ gateArtifact: resolution.approved ? '' : pendingGate.artifact,
281
+ gateFeedback: resolution.approved ? '' : feedback,
235
282
  };
236
283
  };
@@ -7,6 +7,14 @@ export type RunStepEffects = {
7
7
  readonly workspaceCwd?: string | undefined;
8
8
  };
9
9
 
10
+ export type RunAdvanceOptions = {
11
+ /**
12
+ * Marks an explicit human rejection back to the same gated step. This keeps
13
+ * the incoming handoff and bypasses the visit-limit check for this decision.
14
+ */
15
+ readonly sameStepHumanGateRevision?: boolean | undefined;
16
+ };
17
+
10
18
  const completedStep = (
11
19
  run: WorkflowRun,
12
20
  outcome: string,
@@ -37,6 +45,7 @@ const completedStep = (
37
45
  * @param summary - Step handoff summary.
38
46
  * @param now - Update timestamp.
39
47
  * @param effects - Validated declarative effects accepted with this result.
48
+ * @param options - Internal graph-advancement controls.
40
49
  * @returns A new paused, running, or completed workflow state.
41
50
  * @throws When the run, outcome, current step, or transition target is invalid.
42
51
  */
@@ -47,6 +56,7 @@ export const advanceRun = (
47
56
  summary: string,
48
57
  now: number,
49
58
  effects: RunStepEffects = {},
59
+ options: RunAdvanceOptions = {},
50
60
  ): WorkflowRun => {
51
61
  if (run.status !== 'running') {
52
62
  throw new Error(
@@ -78,6 +88,15 @@ export const advanceRun = (
78
88
  : `outcome "${outcome}" cannot bind a workspace`,
79
89
  );
80
90
  }
91
+ if (
92
+ shouldBindWorkspace &&
93
+ run.restartWorkspaceCwd !== undefined &&
94
+ effects.workspaceCwd !== run.restartWorkspaceCwd
95
+ ) {
96
+ throw new Error(
97
+ `restarted workflow must rebind workspace "${run.restartWorkspaceCwd}"`,
98
+ );
99
+ }
81
100
  if (target === '$pause') {
82
101
  return withRunUpdate(
83
102
  run,
@@ -95,6 +114,11 @@ export const advanceRun = (
95
114
  const completed = completedStep(run, outcome, summary, now, effects);
96
115
  const cwd = effects.workspaceCwd ?? run.cwd;
97
116
  if (target === '$done') {
117
+ if (run.restartWorkspaceCwd !== undefined) {
118
+ throw new Error(
119
+ `restarted workflow completed before rebinding workspace "${run.restartWorkspaceCwd}"`,
120
+ );
121
+ }
98
122
  return withRunUpdate(
99
123
  run,
100
124
  {
@@ -103,8 +127,10 @@ export const advanceRun = (
103
127
  currentStepAttempts: undefined,
104
128
  currentStepOmittedAttempts: undefined,
105
129
  ...(cwd ? { cwd } : {}),
130
+ ...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
106
131
  stepHandoff: summary,
107
132
  lastSummary: summary,
133
+ gateArtifact: '',
108
134
  gateFeedback: '',
109
135
  pausedFrom: undefined,
110
136
  pendingGate: undefined,
@@ -118,8 +144,13 @@ export const advanceRun = (
118
144
  throw new Error(`transition target "${target}" does not exist`);
119
145
  }
120
146
 
147
+ const preservesGateRevisionContext =
148
+ target === run.currentStepId &&
149
+ (options.sameStepHumanGateRevision || Boolean(run.gateArtifact));
121
150
  const nextVisitCount = (run.visits[target] ?? 0) + 1;
122
- const isOverVisitLimit = nextVisitCount > workflow.definition.maxStepVisits;
151
+ const isOverVisitLimit =
152
+ !options.sameStepHumanGateRevision &&
153
+ nextVisitCount > workflow.definition.maxStepVisits;
123
154
  const visitLimitChanges: Partial<WorkflowRun> = isOverVisitLimit
124
155
  ? {
125
156
  status: 'paused',
@@ -145,9 +176,11 @@ export const advanceRun = (
145
176
  currentStepAttempts: undefined,
146
177
  currentStepOmittedAttempts: undefined,
147
178
  ...(cwd ? { cwd } : {}),
148
- stepHandoff: summary,
179
+ ...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
180
+ stepHandoff: preservesGateRevisionContext ? run.stepHandoff : summary,
149
181
  lastSummary: summary,
150
- gateFeedback: '',
182
+ gateArtifact: preservesGateRevisionContext ? run.gateArtifact : '',
183
+ gateFeedback: preservesGateRevisionContext ? run.gateFeedback : '',
151
184
  resumeInput: undefined,
152
185
  },
153
186
  now,
@@ -1,7 +1,16 @@
1
1
  import type { LoadedWorkflow } from '../config/types.ts';
2
+ import { createRun } from './create-run.ts';
2
3
  import type { WorkflowRun } from './state-types.ts';
3
4
  import { currentStep, withRunUpdate } from './transition-helpers.ts';
4
5
 
6
+ const completedWorkspaceCwd = (run: WorkflowRun): string | undefined => {
7
+ for (let index = run.history.length - 1; index >= 0; index -= 1) {
8
+ const workspaceCwd = run.history[index]?.workspaceCwd;
9
+ if (workspaceCwd) return workspaceCwd;
10
+ }
11
+ return undefined;
12
+ };
13
+
5
14
  /**
6
15
  * Lists outcomes the active step may submit directly.
7
16
  *
@@ -133,3 +142,63 @@ export const abortRun = (
133
142
  },
134
143
  now,
135
144
  );
145
+
146
+ /**
147
+ * Starts another iteration of a completed workflow while retaining its stable
148
+ * run/worktree identity. The next workspace-binding result must reaffirm the
149
+ * previously selected workspace before the iteration can complete.
150
+ *
151
+ * @param workflow - Current loaded workflow definition.
152
+ * @param run - Completed iteration to restart.
153
+ * @param input - New request, or the prior request when no replacement was supplied.
154
+ * @param baselineTools - Tools available before the new iteration is isolated.
155
+ * @param now - Restart timestamp.
156
+ * @returns A fresh running state for the next iteration.
157
+ */
158
+ export const restartRun = (
159
+ workflow: LoadedWorkflow,
160
+ run: WorkflowRun,
161
+ input: string,
162
+ baselineTools: ReadonlyArray<string>,
163
+ now: number,
164
+ ): WorkflowRun => {
165
+ if (run.status !== 'completed') {
166
+ throw new Error('only a completed workflow can be restarted');
167
+ }
168
+ if (!run.startCwd) {
169
+ throw new Error(
170
+ 'the completed workflow has no captured start directory; start a new workflow instead',
171
+ );
172
+ }
173
+
174
+ const previousIteration = run.iteration ?? 1;
175
+ if (!Number.isSafeInteger(previousIteration) || previousIteration < 1) {
176
+ throw new Error('the completed workflow has an invalid iteration number');
177
+ }
178
+ if (previousIteration >= Number.MAX_SAFE_INTEGER) {
179
+ throw new Error('the workflow iteration limit has been reached');
180
+ }
181
+
182
+ const workspaceCwd = completedWorkspaceCwd(run);
183
+ if (workspaceCwd && run.cwd !== workspaceCwd) {
184
+ throw new Error(
185
+ 'the completed workflow workspace does not match its recorded binding',
186
+ );
187
+ }
188
+
189
+ const restarted = createRun(
190
+ workflow,
191
+ input,
192
+ baselineTools,
193
+ run.runId,
194
+ now,
195
+ run.startCwd,
196
+ previousIteration + 1,
197
+ );
198
+ return {
199
+ ...restarted,
200
+ stepHandoff: run.lastSummary,
201
+ lastSummary: run.lastSummary,
202
+ ...(workspaceCwd ? { restartWorkspaceCwd: workspaceCwd } : {}),
203
+ };
204
+ };
@@ -110,6 +110,7 @@ export const reconcileRun = (
110
110
  pausedFrom: 'running',
111
111
  failedStepId: undefined,
112
112
  pauseReason: `Configuration changed; restarted step "${restartedStep}"`,
113
+ gateArtifact: '',
113
114
  gateFeedback: '',
114
115
  },
115
116
  now,
@@ -127,6 +128,7 @@ export const reconcileRun = (
127
128
  pausedFrom: 'running',
128
129
  failedStepId: undefined,
129
130
  pauseReason: `Configuration changed; restarted step "${run.currentStepId}"`,
131
+ gateArtifact: '',
130
132
  gateFeedback: '',
131
133
  }
132
134
  : {};
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, resolve } from 'node:path';
2
2
  import {
3
+ MAX_GATE_FEEDBACK_CHARS,
3
4
  MAX_RESUME_INPUT_CHARS,
4
5
  MAX_STEP_TRACE_ARTIFACT_CHARS,
5
6
  MAX_STEP_TRACE_ATTEMPTS,
@@ -40,6 +41,7 @@ const isGateApproval = (value: unknown): value is GateApproval =>
40
41
  typeof value.artifact === 'string' &&
41
42
  value.artifact.trim().length > 0 &&
42
43
  typeof value.feedback === 'string' &&
44
+ value.feedback.length <= MAX_GATE_FEEDBACK_CHARS &&
43
45
  typeof value.stepStructuralDigest === 'string' &&
44
46
  value.stepStructuralDigest.length > 0;
45
47
 
@@ -221,6 +223,7 @@ const isGateResolution = (value: unknown): value is GateResolution =>
221
223
  isRecord(value) &&
222
224
  typeof value.approved === 'boolean' &&
223
225
  typeof value.feedback === 'string' &&
226
+ value.feedback.length <= MAX_GATE_FEEDBACK_CHARS &&
224
227
  typeof value.resolvedAt === 'number';
225
228
 
226
229
  const isPendingGate = (value: unknown): value is PendingGate =>
@@ -250,6 +253,10 @@ const isOptionalResumeInput = (value: unknown): value is string | undefined =>
250
253
  value === undefined ||
251
254
  (typeof value === 'string' && value.length <= MAX_RESUME_INPUT_CHARS);
252
255
 
256
+ const isOptionalIteration = (value: unknown): value is number | undefined =>
257
+ value === undefined ||
258
+ (Number.isSafeInteger(value) && (value as number) >= 1);
259
+
253
260
  const isWorkflowRunStatus = (value: unknown): value is WorkflowRunStatus =>
254
261
  value === 'running' ||
255
262
  value === 'paused' ||
@@ -333,13 +340,20 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
333
340
  typeof value.startedAt === 'number' &&
334
341
  typeof value.updatedAt === 'number' &&
335
342
  typeof value.lastSummary === 'string' &&
336
- typeof value.gateFeedback === 'string';
343
+ typeof value.gateFeedback === 'string' &&
344
+ value.gateFeedback.length <= MAX_GATE_FEEDBACK_CHARS;
337
345
  if (!hasValidRequiredFields) return false;
338
346
 
339
347
  const hasValidOptionalFields =
348
+ isOptionalIteration(value.iteration) &&
340
349
  isOptionalString(value.reviewedArtifact) &&
341
350
  isOptionalString(value.reviewedFeedback) &&
351
+ (typeof value.reviewedFeedback !== 'string' ||
352
+ value.reviewedFeedback.length <= MAX_GATE_FEEDBACK_CHARS) &&
342
353
  isOptionalString(value.stepHandoff) &&
354
+ isOptionalString(value.gateArtifact) &&
355
+ (value.restartWorkspaceCwd === undefined ||
356
+ isAbsoluteCwd(value.restartWorkspaceCwd)) &&
343
357
  isOptionalResumeInput(value.resumeInput) &&
344
358
  isOptionalString(value.pauseReason) &&
345
359
  isOptionalString(value.failedStepId) &&
@@ -351,6 +365,15 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
351
365
  const pendingGate = value.pendingGate;
352
366
  if (pendingGate !== undefined && !isPendingGate(pendingGate)) return false;
353
367
 
368
+ if (
369
+ value.restartWorkspaceCwd !== undefined &&
370
+ (value.history as ReadonlyArray<StepHistoryEntry>).some(
371
+ (entry) => entry.workspaceCwd !== undefined,
372
+ )
373
+ ) {
374
+ return false;
375
+ }
376
+
354
377
  return (
355
378
  workflowTraceChars(value as WorkflowRun) <= MAX_WORKFLOW_TRACE_CHARS &&
356
379
  hasValidWorkspaceState(
@@ -1,4 +1,5 @@
1
1
  export const RUN_STATE_VERSION = 1 as const;
2
+ export const MAX_GATE_FEEDBACK_CHARS = 50_000;
2
3
  export const MAX_RESUME_INPUT_CHARS = 16_000;
3
4
  export const MAX_STEP_TRACE_TASK_CHARS = 64_000;
4
5
  export const MAX_STEP_TRACE_ATTEMPTS = 16;
@@ -124,6 +125,8 @@ export type PendingGate = {
124
125
 
125
126
  export type WorkflowRun = {
126
127
  readonly stateVersion: typeof RUN_STATE_VERSION;
128
+ /** One-based attempt number within a restartable workflow/worktree lineage. */
129
+ readonly iteration?: number | undefined;
127
130
  readonly runId: string;
128
131
  readonly workflowId: string;
129
132
  readonly workflowDigest: string;
@@ -152,12 +155,19 @@ export type WorkflowRun = {
152
155
  readonly startCwd?: string | undefined;
153
156
  /** Current canonical execution directory for delegated workflow steps. */
154
157
  readonly cwd?: string | undefined;
158
+ /**
159
+ * Exact workspace that a restarted iteration must bind before it can finish.
160
+ * This prevents a missing prior worktree from being silently replaced.
161
+ */
162
+ readonly restartWorkspaceCwd?: string | undefined;
155
163
  /**
156
164
  * Input inherited from the previous completed step. Unlike `lastSummary`,
157
165
  * this survives a paused attempt of the current step.
158
166
  */
159
167
  readonly stepHandoff?: string | undefined;
160
168
  readonly lastSummary: string;
169
+ /** Opaque artifact returned by the latest rejected or failed gate. */
170
+ readonly gateArtifact?: string | undefined;
161
171
  readonly gateFeedback: string;
162
172
  /** User-authored guidance supplied for the current resume attempt. */
163
173
  readonly resumeInput?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  export { createRun } from './create-run.ts';
2
2
  export { isWorkflowRun } from './run-validation.ts';
3
3
  export {
4
+ MAX_GATE_FEEDBACK_CHARS,
4
5
  MAX_RESUME_INPUT_CHARS,
5
6
  MAX_STEP_TRACE_ATTEMPTS,
6
7
  MAX_STEP_TRACE_LOG_CHARS,
@@ -11,6 +11,7 @@ export {
11
11
  allowedOutcomes,
12
12
  failRun,
13
13
  pauseRun,
14
+ restartRun,
14
15
  resumeRun,
15
16
  setResumeInput,
16
17
  } from './run-lifecycle.ts';
@@ -77,6 +77,12 @@ export type HarnessActionContext = {
77
77
  startContext: WorkflowStartContext,
78
78
  sessionEpoch: number,
79
79
  ) => Promise<void>;
80
+ restart: (input: string, context: ExtensionCommandContext) => Promise<void>;
81
+ restartNow: (
82
+ input: string,
83
+ startContext: WorkflowStartContext,
84
+ sessionEpoch: number,
85
+ ) => Promise<void>;
80
86
  pause: (reason: string, context: ExtensionCommandContext) => Promise<void>;
81
87
  pauseNow: (reason: string, context: ExtensionCommandContext) => Promise<void>;
82
88
  resume: (input: string, context: ExtensionCommandContext) => Promise<void>;
@@ -190,6 +190,8 @@ function enqueueMutation(
190
190
  function persist(this: HarnessActionContext): void {
191
191
  if (this.run) {
192
192
  this.pi.appendEntry(STATE_ENTRY_TYPE, structuredClone(this.run));
193
+ const session = this.latestContext?.sessionManager;
194
+ if (session) this.dependencies.flushUnwrittenSession(session);
193
195
  }
194
196
  }
195
197
 
@@ -33,6 +33,7 @@ import {
33
33
  resolveWorkspaceDirectory,
34
34
  type ResolveWorkspaceDirectoryOptions,
35
35
  } from './workspace-directory.ts';
36
+ import { flushUnwrittenSession } from './session-persistence.ts';
36
37
 
37
38
  const MAX_DELEGATED_RESULT_BYTES = 1024 * 1024;
38
39
 
@@ -71,6 +72,8 @@ export type WorkflowHarnessDependencies = {
71
72
  pi: ExtensionAPI,
72
73
  ) => MainStepRuntimeController;
73
74
  readonly createMutationQueue: () => SerialTaskQueueController;
75
+ /** Makes a new Pi session durable before its first assistant message. */
76
+ readonly flushUnwrittenSession: typeof flushUnwrittenSession;
74
77
  readonly scheduleInterval: (
75
78
  operation: () => void,
76
79
  intervalMs: number,
@@ -154,6 +157,7 @@ const DEFAULT_DEPENDENCIES: WorkflowHarnessDependencies = {
154
157
  createSubagentClient: (pi) => createSubagentDelegationClient(pi.events),
155
158
  createMainStepRuntime: (pi) => createMainStepRuntime({ pi }),
156
159
  createMutationQueue: createSerialTaskQueue,
160
+ flushUnwrittenSession,
157
161
  scheduleInterval: (operation, intervalMs) =>
158
162
  setInterval(operation, intervalMs),
159
163
  cancelInterval: (timer) => {
@@ -107,9 +107,22 @@ function registerLifecycle(this: HarnessActionContext): void {
107
107
  this.isSessionActive = true;
108
108
  });
109
109
 
110
- this.pi.on('session_shutdown', async () => {
110
+ this.pi.on('session_shutdown', async (_event, context) => {
111
111
  this.sessionEpoch += 1;
112
112
  this.isSessionActive = false;
113
+ if (this.run) {
114
+ this.latestContext = context;
115
+ try {
116
+ this.persist();
117
+ } catch (error) {
118
+ context.ui.notify(
119
+ `Workflow checkpoint could not be saved before shutdown: ${
120
+ error instanceof Error ? error.message : String(error)
121
+ }`,
122
+ 'error',
123
+ );
124
+ }
125
+ }
113
126
  this.cancelPromptReview();
114
127
  this.mainSteps.deactivate();
115
128
  await this.cancelActiveDelegation('Pi session shut down');
@@ -0,0 +1,66 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import type {
3
+ ExtensionContext,
4
+ SessionManager,
5
+ } from '@earendil-works/pi-coding-agent';
6
+
7
+ type SessionSnapshot = Pick<
8
+ ExtensionContext['sessionManager'],
9
+ 'getEntries' | 'getHeader' | 'getSessionFile'
10
+ >;
11
+
12
+ type AdoptableSessionSnapshot = SessionSnapshot &
13
+ Pick<SessionManager, 'setSessionFile'>;
14
+
15
+ const isAlreadyPersisted = (error: unknown): boolean =>
16
+ error instanceof Error && 'code' in error && error.code === 'EEXIST';
17
+
18
+ function getAdoptableSession(
19
+ session: SessionSnapshot,
20
+ ): AdoptableSessionSnapshot {
21
+ const adoptable = session as Partial<AdoptableSessionSnapshot>;
22
+ if (typeof adoptable.setSessionFile !== 'function') {
23
+ throw new Error(
24
+ 'This Pi runtime cannot adopt a materialized workflow session file',
25
+ );
26
+ }
27
+ return adoptable as AdoptableSessionSnapshot;
28
+ }
29
+
30
+ /**
31
+ * Materializes a Pi session before its first regular assistant message.
32
+ *
33
+ * Pi defers creating a new session file until it records a regular assistant
34
+ * message. A workflow made entirely of delegated steps can therefore have
35
+ * checkpoint entries in memory but no file to reopen. After creating a public
36
+ * session snapshot, this re-adopts the same file through Pi's public
37
+ * `SessionManager#setSessionFile` method. That marks the running manager as
38
+ * flushed, allowing Pi to append future custom and assistant entries normally.
39
+ *
40
+ * @param session - Public snapshot view supplied by Pi's extension context.
41
+ * @returns `true` when this call created the session file.
42
+ */
43
+ export function flushUnwrittenSession(session: SessionSnapshot): boolean {
44
+ const sessionFile = session.getSessionFile();
45
+ const header = session.getHeader();
46
+ if (!sessionFile || !header) return false;
47
+ if (existsSync(sessionFile)) return false;
48
+ const adoptable = getAdoptableSession(session);
49
+
50
+ const serialized = [header, ...session.getEntries()]
51
+ .map((entry) => JSON.stringify(entry))
52
+ .join('\n');
53
+
54
+ try {
55
+ writeFileSync(sessionFile, `${serialized}\n`, {
56
+ encoding: 'utf8',
57
+ flag: 'wx',
58
+ mode: 0o600,
59
+ });
60
+ adoptable.setSessionFile(sessionFile);
61
+ return true;
62
+ } catch (error) {
63
+ if (isAlreadyPersisted(error)) return false;
64
+ throw error;
65
+ }
66
+ }