@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.
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import { createRun } from '../engine/state.ts';
3
+ import { restartRun } from '../engine/transitions.ts';
4
+ import type { LoadedWorkflow } from '../config/types.ts';
3
5
  import { analyzeWorkflow, formatWorkflowDoctor } from '../workflow-doctor.ts';
4
6
  import { formatWorkflowList } from '../workflow-list.ts';
5
7
  import type { HarnessActionContext as FullHarnessActionContext } from './action-context.ts';
@@ -36,6 +38,32 @@ function isCurrentSession(
36
38
  return session.isSessionActive && session.sessionEpoch === sessionEpoch;
37
39
  }
38
40
 
41
+ type RestartWorkspaceBinding = {
42
+ readonly cwd: string;
43
+ readonly allowedRoots: ReadonlyArray<string>;
44
+ };
45
+
46
+ function completedWorkspaceBinding(
47
+ run: NonNullable<HarnessActionContext['run']>,
48
+ workflow: LoadedWorkflow,
49
+ ): RestartWorkspaceBinding | undefined {
50
+ for (let index = run.history.length - 1; index >= 0; index -= 1) {
51
+ const entry = run.history[index];
52
+ if (!entry?.workspaceCwd) continue;
53
+ const step = workflow.definition.steps[entry.stepId];
54
+ if (!step?.workspace || !step.workspace.bindOn.includes(entry.outcome)) {
55
+ throw new Error(
56
+ `workspace-binding step "${entry.stepId}" no longer matches the completed iteration`,
57
+ );
58
+ }
59
+ return {
60
+ cwd: entry.workspaceCwd,
61
+ allowedRoots: step.workspace.allowedRoots,
62
+ };
63
+ }
64
+ return undefined;
65
+ }
66
+
39
67
  export type StartActions = {
40
68
  listWorkflows: (
41
69
  this: HarnessActionContext,
@@ -53,6 +81,12 @@ export type StartActions = {
53
81
  startContext: WorkflowStartContext,
54
82
  sessionEpoch: number,
55
83
  ) => Promise<void>;
84
+ restartNow: (
85
+ this: HarnessActionContext,
86
+ input: string,
87
+ startContext: WorkflowStartContext,
88
+ sessionEpoch: number,
89
+ ) => Promise<void>;
56
90
  reloadNow: (
57
91
  this: HarnessActionContext,
58
92
  context: ExtensionCommandContext,
@@ -231,6 +265,159 @@ async function startNow(
231
265
  this.launchCurrentStep(workflow);
232
266
  }
233
267
 
268
+ async function restartNow(
269
+ this: HarnessActionContext,
270
+ input: string,
271
+ startContext: WorkflowStartContext,
272
+ sessionEpoch: number,
273
+ ): Promise<void> {
274
+ const { context } = startContext;
275
+ const completedRun = this.run;
276
+ if (!completedRun || completedRun.status !== 'completed') {
277
+ context.ui.notify('Only a completed workflow can be restarted', 'warning');
278
+ return;
279
+ }
280
+ if (this.activeDelegation) {
281
+ context.ui.notify(
282
+ `Cannot restart while subagent "${this.activeDelegation.agent}" is still cancelling`,
283
+ 'warning',
284
+ );
285
+ return;
286
+ }
287
+ if (!completedRun.startCwd) {
288
+ context.ui.notify(
289
+ 'Cannot restart this workflow on the same worktree because its original start directory was not captured; start a new workflow instead',
290
+ 'error',
291
+ );
292
+ return;
293
+ }
294
+ if (!context.isIdle()) {
295
+ context.abort();
296
+ await startContext.waitForIdle();
297
+ }
298
+ if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
299
+ context.ui.notify(
300
+ 'Workflow restart was superseded by a session or workflow change',
301
+ 'warning',
302
+ );
303
+ return;
304
+ }
305
+
306
+ this.captureSkills(startContext.skills());
307
+ if (!(await this.reloadCatalog(context, false))) {
308
+ context.ui.notify(
309
+ 'Workflow restart was superseded by a newer configuration load',
310
+ 'warning',
311
+ );
312
+ return;
313
+ }
314
+ if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
315
+ context.ui.notify(
316
+ 'Workflow restart was superseded by a session or workflow change',
317
+ 'warning',
318
+ );
319
+ return;
320
+ }
321
+
322
+ const workflow = this.catalog.workflows.get(completedRun.workflowId);
323
+ if (!workflow) {
324
+ context.ui.notify(
325
+ `Workflow "${completedRun.workflowId}" is no longer loaded`,
326
+ 'error',
327
+ );
328
+ return;
329
+ }
330
+ const livenessErrors = analyzeWorkflow(workflow.definition).issues.filter(
331
+ (issue) => issue.level === 'error',
332
+ );
333
+ if (livenessErrors.length > 0) {
334
+ context.ui.notify(
335
+ `Cannot restart workflow; run /workflow-doctor ${workflow.definition.id}:\n${livenessErrors.map((issue) => issue.message).join('\n')}`,
336
+ 'error',
337
+ );
338
+ return;
339
+ }
340
+ const preflightErrors = this.preflight(workflow, workflow.definition.start);
341
+ if (preflightErrors.length > 0) {
342
+ context.ui.notify(
343
+ `Cannot restart workflow:\n${preflightErrors.join('\n')}`,
344
+ 'error',
345
+ );
346
+ return;
347
+ }
348
+
349
+ let canonicalStartCwd: string;
350
+ let canonicalSessionCwd: string;
351
+ try {
352
+ canonicalStartCwd = this.dependencies.resolveWorkspaceDirectory({
353
+ candidateCwd: completedRun.startCwd,
354
+ startCwd: completedRun.startCwd,
355
+ allowedRoots: ['.'],
356
+ });
357
+ canonicalSessionCwd = this.dependencies.resolveWorkspaceDirectory({
358
+ candidateCwd: context.cwd,
359
+ startCwd: context.cwd,
360
+ allowedRoots: ['.'],
361
+ });
362
+ } catch (error) {
363
+ context.ui.notify(
364
+ `Cannot restart workflow on its captured worktree: ${
365
+ error instanceof Error ? error.message : String(error)
366
+ }`,
367
+ 'error',
368
+ );
369
+ return;
370
+ }
371
+ if (
372
+ canonicalStartCwd !== completedRun.startCwd ||
373
+ canonicalSessionCwd !== canonicalStartCwd
374
+ ) {
375
+ context.ui.notify(
376
+ 'Current session cwd does not match the captured workflow start directory',
377
+ 'error',
378
+ );
379
+ return;
380
+ }
381
+
382
+ try {
383
+ const binding = completedWorkspaceBinding(completedRun, workflow);
384
+ if (binding) {
385
+ const canonicalWorkspaceCwd = this.dependencies.resolveWorkspaceDirectory(
386
+ {
387
+ candidateCwd: binding.cwd,
388
+ startCwd: canonicalStartCwd,
389
+ allowedRoots: binding.allowedRoots,
390
+ },
391
+ );
392
+ if (canonicalWorkspaceCwd !== binding.cwd) {
393
+ throw new Error(
394
+ 'previous workspace no longer resolves to its captured canonical directory',
395
+ );
396
+ }
397
+ }
398
+ this.run = restartRun(
399
+ workflow,
400
+ completedRun,
401
+ input.trim() || completedRun.input,
402
+ this.pi.getActiveTools(),
403
+ this.dependencies.now(),
404
+ );
405
+ } catch (error) {
406
+ context.ui.notify(
407
+ `Cannot restart workflow on the same worktree: ${
408
+ error instanceof Error ? error.message : String(error)
409
+ }`,
410
+ 'error',
411
+ );
412
+ return;
413
+ }
414
+
415
+ this.persist();
416
+ this.isolateMainSessionTools();
417
+ this.updateStatus();
418
+ this.launchCurrentStep(workflow);
419
+ }
420
+
234
421
  async function reloadNow(
235
422
  this: HarnessActionContext,
236
423
  context: ExtensionCommandContext,
@@ -253,5 +440,5 @@ async function reloadNow(
253
440
  * Returns workflow listing, start, and reload actions for harness composition.
254
441
  */
255
442
  export function createStartActions(): StartActions {
256
- return { listWorkflows, doctorWorkflows, startNow, reloadNow };
443
+ return { listWorkflows, doctorWorkflows, startNow, restartNow, reloadNow };
257
444
  }
package/src/harness.ts CHANGED
@@ -123,6 +123,11 @@ export class WorkflowHarness implements WorkflowCommandController {
123
123
  startContext: WorkflowStartContext,
124
124
  sessionEpoch: number,
125
125
  ) => Promise<void> = START_ACTIONS.startNow;
126
+ private readonly restartNow: (
127
+ input: string,
128
+ startContext: WorkflowStartContext,
129
+ sessionEpoch: number,
130
+ ) => Promise<void> = START_ACTIONS.restartNow;
126
131
  private readonly reloadNow: (
127
132
  context: ExtensionCommandContext,
128
133
  ) => Promise<void> = START_ACTIONS.reloadNow;
@@ -345,6 +350,21 @@ export class WorkflowHarness implements WorkflowCommandController {
345
350
  );
346
351
  }
347
352
 
353
+ /** Starts another completed iteration in its existing workflow worktree. */
354
+ restart(input: string, context: ExtensionCommandContext): Promise<void> {
355
+ return this.enqueueMutation(context, (sessionEpoch) =>
356
+ this.restartNow(
357
+ input,
358
+ {
359
+ context,
360
+ skills: () => context.getSystemPromptOptions().skills,
361
+ waitForIdle: () => context.waitForIdle(),
362
+ },
363
+ sessionEpoch,
364
+ ),
365
+ );
366
+ }
367
+
348
368
  /** Pauses the active workflow while retaining its checkpoint. */
349
369
  pause(reason: string, context: ExtensionCommandContext): Promise<void> {
350
370
  return this.enqueueMutation(context, () => this.pauseNow(reason, context));
@@ -58,6 +58,20 @@ export function buildDelegatedHandoffSection(
58
58
  ];
59
59
  }
60
60
 
61
+ /** Builds the immutable same-worktree constraint for a restarted iteration. */
62
+ export function buildRestartWorkspaceSection(
63
+ workspaceCwd: string | undefined,
64
+ ): ReadonlyArray<string> {
65
+ if (!workspaceCwd) return [];
66
+ return [
67
+ '## Restart workspace constraint',
68
+ '',
69
+ `This iteration must reuse and rebind exactly this existing workspace: ${workspaceCwd}`,
70
+ 'Do not create or substitute another workspace. If it cannot be safely reused, complete with a configured non-binding outcome that pauses the workflow.',
71
+ '',
72
+ ];
73
+ }
74
+
61
75
  /**
62
76
  * Builds non-interactive recovery guidance specific to delegated steps.
63
77
  *
@@ -4,6 +4,7 @@ import { createStepContract } from './step-contract.ts';
4
4
  import {
5
5
  buildDelegatedCompletionInstructions,
6
6
  buildDelegatedHandoffSection,
7
+ buildRestartWorkspaceSection,
7
8
  buildResourceSection,
8
9
  } from './step-sections.ts';
9
10
  import {
@@ -119,6 +120,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
119
120
  '',
120
121
  `Workflow: ${workflow.definition.id}`,
121
122
  `Run: ${run.runId}`,
123
+ `Iteration: ${run.iteration ?? 1}`,
122
124
  `Step: ${run.currentStepId} (${step.title})`,
123
125
  ...(isDelegated
124
126
  ? [
@@ -132,6 +134,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
132
134
  prompt,
133
135
  '',
134
136
  ...(isDelegated ? buildDelegatedHandoffSection(handoff) : []),
137
+ ...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
135
138
  ...buildResumeInputSection(
136
139
  run,
137
140
  RESUME_INPUT_PLACEHOLDER.test(promptTemplate),
@@ -72,6 +72,7 @@ export function createTemplateValues({
72
72
  return {
73
73
  'workflow.input': run.input,
74
74
  'workflow.id': workflow.definition.id,
75
+ 'workflow.iteration': String(run.iteration ?? 1),
75
76
  'run.id': run.runId,
76
77
  'step.id': run.currentStepId,
77
78
  'step.title': step.title,
@@ -81,5 +82,6 @@ export function createTemplateValues({
81
82
  'gate.artifact': run.gateArtifact ?? '',
82
83
  'gate.feedback': run.gateFeedback,
83
84
  'resume.input': run.resumeInput ?? '',
85
+ 'restart.workspace': run.restartWorkspaceCwd ?? '',
84
86
  };
85
87
  }
@@ -82,6 +82,9 @@ export function renderSummaryLines(
82
82
  ]
83
83
  : []),
84
84
  ...keyValueLines(theme, 'run', run.runId, width),
85
+ ...(run.iteration && run.iteration > 1
86
+ ? [...keyValueLines(theme, 'iteration', String(run.iteration), width)]
87
+ : []),
85
88
  ...keyValueLines(
86
89
  theme,
87
90
  'status',