@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.
- package/README.md +19 -955
- package/dist/index.js +841 -535
- package/examples/mr-comments.workflow.yaml +1 -1
- package/examples/prompts/mr-comments/plan.md +8 -1
- package/examples/starter-kit/mr-comment.workflow.yaml +1 -1
- package/examples/starter-kit/mr-review.workflow.yaml +4 -2
- package/examples/starter-kit/steps/mr-comment/plan.md +9 -2
- package/examples/starter-kit/steps/mr-review/publish.md +9 -2
- package/examples/starter-kit/steps/mr-review/review.md +9 -2
- package/examples/starter-kit/steps/mr-review/verify.md +16 -5
- package/examples/starter-kit/steps/shared/prepare-workspace.md +42 -4
- package/examples/starter-kit/steps/ticket/plan.md +31 -2
- package/examples/starter-kit/steps/work/plan.md +31 -2
- package/examples/starter-kit/ticket.workflow.yaml +14 -2
- package/examples/starter-kit/work.workflow.yaml +14 -2
- package/package.json +1 -1
- package/schemas/workflow.schema.json +1 -0
- package/src/command-names.ts +1 -0
- package/src/commands.ts +14 -0
- package/src/config/types.ts +1 -1
- package/src/config/validation/prompt.ts +3 -0
- package/src/engine/create-run.ts +4 -0
- package/src/engine/gate-transitions.ts +80 -33
- package/src/engine/run-advance.ts +36 -3
- package/src/engine/run-lifecycle.ts +69 -0
- package/src/engine/run-reconciliation.ts +2 -0
- package/src/engine/run-validation.ts +24 -1
- package/src/engine/state-types.ts +10 -0
- package/src/engine/state.ts +1 -0
- package/src/engine/transitions.ts +1 -0
- package/src/harness/action-context.ts +6 -0
- package/src/harness/core-actions.ts +2 -0
- package/src/harness/dependencies.ts +4 -0
- package/src/harness/lifecycle-actions.ts +14 -1
- package/src/harness/session-persistence.ts +66 -0
- package/src/harness/start-actions.ts +188 -1
- package/src/harness.ts +20 -0
- package/src/prompt/step-sections.ts +14 -0
- package/src/prompt/step-task.ts +3 -0
- package/src/prompt/template.ts +6 -3
- package/src/workflow-doctor.ts +1 -1
- package/src/workflow-status/render-summary.ts +3 -0
- 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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
235
|
+
gateArtifact: resolution.approved ? '' : pendingGate.artifact,
|
|
236
|
+
gateFeedback: resolution.approved ? '' : feedback,
|
|
203
237
|
},
|
|
204
238
|
now,
|
|
205
239
|
);
|
|
206
|
-
const
|
|
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
|
|
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:
|
|
277
|
+
reviewedFeedback: feedback,
|
|
232
278
|
}
|
|
233
279
|
: {}),
|
|
234
|
-
|
|
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 =
|
|
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
|
-
|
|
179
|
+
...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
|
|
180
|
+
stepHandoff: preservesGateRevisionContext ? run.stepHandoff : summary,
|
|
149
181
|
lastSummary: summary,
|
|
150
|
-
|
|
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;
|
package/src/engine/state.ts
CHANGED
|
@@ -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
|
+
}
|