@wichayutdew/pi-workflows 2.1.0 → 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.
@@ -105,7 +105,7 @@ steps:
105
105
  blocked: $pause
106
106
 
107
107
  verify:
108
- title: Verify the review is observable remotely
108
+ title: Verify or return the review for publication repair
109
109
  prompt:
110
110
  file: steps/mr-review/verify.md
111
111
  subagent:
@@ -127,4 +127,6 @@ steps:
127
127
  tools: [bash]
128
128
  transitions:
129
129
  verified: $done
130
+ failed: publish
131
+ retry: verify
130
132
  blocked: $pause
@@ -10,6 +10,9 @@ Approved review:
10
10
  Approval feedback:
11
11
  {{reviewed.feedback}}
12
12
 
13
+ Previous step handoff:
14
+ {{last.summary}}
15
+
13
16
  Parse the approved Publication contract. Refresh the same review and head SHA
14
17
  through configured read-only MCP/CLI/cURL calls. For every action, first query
15
18
  the public review collections and skip it only when its exact marker, body,
@@ -26,5 +29,9 @@ After a mutation-capable call is attempted, ambiguity is `blocked`; do not
26
29
  blindly replay it. Call `structured_output` alone with outcome `published` only
27
30
  after every approved effect succeeded now or was proven already present.
28
31
  Summarize the URL/head, per-action pre-state, attempted/skipped result, exact
29
- correlation, and remaining work. Use `blocked` with the same ledger when
30
- freshness, execution, or correlation fails.
32
+ correlation, and remaining work. When the previous handoff is an actionable
33
+ verification finding, treat it as a corrective publication handoff: re-check
34
+ the exact approved effect and retry only when its absence is conclusive. Never
35
+ use it to alter the approved content, target, or action list. Use `blocked`
36
+ with the same ledger when freshness, execution, or correlation is ambiguous or
37
+ unsafe.
@@ -17,8 +17,19 @@ review collections and prove every exact marker, body, head, effect kind,
17
17
  optional path/line anchor, and remote identifier. The publication ledger alone
18
18
  is not proof.
19
19
 
20
- Call `structured_output` alone with outcome `verified` only when all approved
21
- effects are observable exactly once or in the explicitly idempotent form
22
- described by the contract. Summarize the canonical URL, current head, verified
23
- remote identifiers/URLs and anchors, action count, and final verdict. Use
24
- `blocked` when an effect is absent, stale, ambiguous, or different.
20
+ Call `structured_output` alone with:
21
+
22
+ - `verified` only when all approved effects are observable exactly once or in
23
+ the explicitly idempotent form described by the contract;
24
+ - `failed` for an actionable, unambiguous missing or mismatched approved
25
+ effect. Its self-contained summary becomes the next publication worker's
26
+ corrective handoff, so include the expected and observed state, exact
27
+ evidence, and the smallest safe repair;
28
+ - `retry` for a transient read-only verification failure after safe equivalent
29
+ checks were attempted;
30
+ - `blocked` only when the review, head, target, or remote result is stale,
31
+ ambiguous, or unsafe to repair automatically.
32
+
33
+ For `verified` and `failed`, summarize the canonical URL, current head,
34
+ verified or missing remote identifiers/URLs and anchors, action count, and
35
+ final verdict. Do not mutate state yourself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wichayutdew/pi-workflows",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "A declarative, pauseable workflow harness for Pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,6 +52,7 @@
52
52
  "workflow-list",
53
53
  "workflow-pause",
54
54
  "workflow-reload",
55
+ "workflow-restart",
55
56
  "workflow-resume",
56
57
  "workflow-start",
57
58
  "workflow-status"
@@ -7,6 +7,7 @@ export const HARNESS_COMMAND_NAMES = [
7
7
  'workflow-list',
8
8
  'workflow-pause',
9
9
  'workflow-reload',
10
+ 'workflow-restart',
10
11
  'workflow-resume',
11
12
  'workflow-start',
12
13
  ] as const;
package/src/commands.ts CHANGED
@@ -23,6 +23,11 @@ export type WorkflowCommandController = {
23
23
  input: string,
24
24
  context: ExtensionCommandContext,
25
25
  ) => Promise<void>;
26
+ /** Restarts a completed workflow in its existing worktree. */
27
+ readonly restart: (
28
+ input: string,
29
+ context: ExtensionCommandContext,
30
+ ) => Promise<void>;
26
31
  /** Pauses the active workflow. */
27
32
  readonly pause: (
28
33
  reason: string,
@@ -126,6 +131,15 @@ export function createHarnessCommands(
126
131
  },
127
132
  },
128
133
  createStartCommand(controller),
134
+ {
135
+ name: 'workflow-restart',
136
+ options: {
137
+ description:
138
+ 'Restart the completed workflow in its worktree: /workflow-restart [input]',
139
+ handler: async (input, context) =>
140
+ controller.restart(input.trim(), context),
141
+ },
142
+ },
129
143
  {
130
144
  name: 'workflow-pause',
131
145
  options: {
@@ -1,6 +1,7 @@
1
1
  const PROMPT_VARIABLES = new Set([
2
2
  'workflow.input',
3
3
  'workflow.id',
4
+ 'workflow.iteration',
4
5
  'run.id',
5
6
  'step.id',
6
7
  'step.title',
@@ -10,6 +11,7 @@ const PROMPT_VARIABLES = new Set([
10
11
  'gate.artifact',
11
12
  'gate.feedback',
12
13
  'resume.input',
14
+ 'restart.workspace',
13
15
  ]);
14
16
 
15
17
  /** Validate template variables embedded in resolved prompt text. */
@@ -11,6 +11,7 @@ import type { WorkflowRun } from './state-types.ts';
11
11
  * @param runId - Stable run identifier.
12
12
  * @param now - Creation timestamp.
13
13
  * @param cwd - Canonical working directory captured at workflow start.
14
+ * @param iteration - One-based restart iteration within this worktree lineage.
14
15
  * @returns A new running workflow state.
15
16
  */
16
17
  export const createRun = (
@@ -20,10 +21,12 @@ export const createRun = (
20
21
  runId: string,
21
22
  now: number,
22
23
  cwd?: string,
24
+ iteration = 1,
23
25
  ): WorkflowRun => {
24
26
  const startStepId = workflow.definition.start;
25
27
  return {
26
28
  stateVersion: RUN_STATE_VERSION,
29
+ iteration,
27
30
  runId,
28
31
  workflowId: workflow.definition.id,
29
32
  workflowDigest: workflow.digest,
@@ -88,6 +88,15 @@ export const advanceRun = (
88
88
  : `outcome "${outcome}" cannot bind a workspace`,
89
89
  );
90
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
+ }
91
100
  if (target === '$pause') {
92
101
  return withRunUpdate(
93
102
  run,
@@ -105,6 +114,11 @@ export const advanceRun = (
105
114
  const completed = completedStep(run, outcome, summary, now, effects);
106
115
  const cwd = effects.workspaceCwd ?? run.cwd;
107
116
  if (target === '$done') {
117
+ if (run.restartWorkspaceCwd !== undefined) {
118
+ throw new Error(
119
+ `restarted workflow completed before rebinding workspace "${run.restartWorkspaceCwd}"`,
120
+ );
121
+ }
108
122
  return withRunUpdate(
109
123
  run,
110
124
  {
@@ -113,6 +127,7 @@ export const advanceRun = (
113
127
  currentStepAttempts: undefined,
114
128
  currentStepOmittedAttempts: undefined,
115
129
  ...(cwd ? { cwd } : {}),
130
+ ...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
116
131
  stepHandoff: summary,
117
132
  lastSummary: summary,
118
133
  gateArtifact: '',
@@ -161,6 +176,7 @@ export const advanceRun = (
161
176
  currentStepAttempts: undefined,
162
177
  currentStepOmittedAttempts: undefined,
163
178
  ...(cwd ? { cwd } : {}),
179
+ ...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
164
180
  stepHandoff: preservesGateRevisionContext ? run.stepHandoff : summary,
165
181
  lastSummary: summary,
166
182
  gateArtifact: preservesGateRevisionContext ? run.gateArtifact : '',
@@ -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
+ };
@@ -253,6 +253,10 @@ const isOptionalResumeInput = (value: unknown): value is string | undefined =>
253
253
  value === undefined ||
254
254
  (typeof value === 'string' && value.length <= MAX_RESUME_INPUT_CHARS);
255
255
 
256
+ const isOptionalIteration = (value: unknown): value is number | undefined =>
257
+ value === undefined ||
258
+ (Number.isSafeInteger(value) && (value as number) >= 1);
259
+
256
260
  const isWorkflowRunStatus = (value: unknown): value is WorkflowRunStatus =>
257
261
  value === 'running' ||
258
262
  value === 'paused' ||
@@ -341,12 +345,15 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
341
345
  if (!hasValidRequiredFields) return false;
342
346
 
343
347
  const hasValidOptionalFields =
348
+ isOptionalIteration(value.iteration) &&
344
349
  isOptionalString(value.reviewedArtifact) &&
345
350
  isOptionalString(value.reviewedFeedback) &&
346
351
  (typeof value.reviewedFeedback !== 'string' ||
347
352
  value.reviewedFeedback.length <= MAX_GATE_FEEDBACK_CHARS) &&
348
353
  isOptionalString(value.stepHandoff) &&
349
354
  isOptionalString(value.gateArtifact) &&
355
+ (value.restartWorkspaceCwd === undefined ||
356
+ isAbsoluteCwd(value.restartWorkspaceCwd)) &&
350
357
  isOptionalResumeInput(value.resumeInput) &&
351
358
  isOptionalString(value.pauseReason) &&
352
359
  isOptionalString(value.failedStepId) &&
@@ -358,6 +365,15 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
358
365
  const pendingGate = value.pendingGate;
359
366
  if (pendingGate !== undefined && !isPendingGate(pendingGate)) return false;
360
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
+
361
377
  return (
362
378
  workflowTraceChars(value as WorkflowRun) <= MAX_WORKFLOW_TRACE_CHARS &&
363
379
  hasValidWorkspaceState(
@@ -125,6 +125,8 @@ export type PendingGate = {
125
125
 
126
126
  export type WorkflowRun = {
127
127
  readonly stateVersion: typeof RUN_STATE_VERSION;
128
+ /** One-based attempt number within a restartable workflow/worktree lineage. */
129
+ readonly iteration?: number | undefined;
128
130
  readonly runId: string;
129
131
  readonly workflowId: string;
130
132
  readonly workflowDigest: string;
@@ -153,6 +155,11 @@ export type WorkflowRun = {
153
155
  readonly startCwd?: string | undefined;
154
156
  /** Current canonical execution directory for delegated workflow steps. */
155
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;
156
163
  /**
157
164
  * Input inherited from the previous completed step. Unlike `lastSummary`,
158
165
  * this survives a paused attempt of the current step.
@@ -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
+ }