@osolmaz/pi-workflows 0.16.6 → 0.16.7

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.
@@ -0,0 +1,84 @@
1
+ ---
2
+ title: Workflow handoff and fixed run definitions
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-09-07
5
+ status: complete
6
+ ---
7
+
8
+ # Workflow handoff and fixed run definitions
9
+
10
+ Keep the architecture small. Make ownership, delivery, and recovery rules explicit.
11
+ This corrects regressions found after the [durable execution work](2026-09-06-durable-execution-plan.md).
12
+ Normal chat currently leaves a turn record that blocks the first workflow message.
13
+ Forced resume accepts changed source, then fails against the old saved graph.
14
+
15
+ ## Scope
16
+
17
+ Change only pi-workflows. Implement, test, commit, push, review, and rebase-merge this
18
+ change into main. Do not release, install packages into user profiles, alter live
19
+ runs, reset databases, or modify Pi itself. No new workflow primitive, database,
20
+ transport, persisted schema, compatibility path, or hidden model retry is needed.
21
+
22
+ ## Design
23
+
24
+ - The extension tracks only workflow-owned turns. Its local states are absent,
25
+ delivering, running, and settled awaiting server acknowledgment. Normal chat
26
+ creates no owned turn. Pi automatic retries retain the same owned turn.
27
+ - Save delivery identity before the public `sendMessage` call. Confirm delivery
28
+ from the public session branch. Retry reports with the same identity; never
29
+ resend a message merely because its acknowledgment was lost. Preserve an exact
30
+ settled response until submission and turn-end acknowledgment succeed.
31
+ - Resume requires the original source and graph. Remove the `force` option.
32
+ Reject changes before any durable mutation. A changed definition requires an
33
+ explicit new run; accepted outputs and approvals are not silently transferred.
34
+ - Derive delivery, active model work, required results, and pause labels from
35
+ existing message, turn, request, and run records. An unconfirmed message is not
36
+ proof that the agent received it. A start receipt proves only that a run exists.
37
+ - Use Pi's public `agent_start`, `agent_end`, `agent_settled`, `sendMessage`, and
38
+ session branch APIs. Pi's normal message delivery appends the existing custom
39
+ message entry. There are no new Pi session fields or changes to Pi internals.
40
+
41
+ ## Acceptance and validation
42
+
43
+ Tests must cover ordinary chat before and during start, full step delivery and
44
+ submission, automatic retries, delayed branch and turn acknowledgments, reconnect,
45
+ cancellation, pause/resume, and changed source or graph. Assert delivery counts,
46
+ exact receipts, and durable state. Rejected resume must leave the run unchanged.
47
+ Use real Pi with a mock provider for automated full-lifecycle tests, then a
48
+ separate authenticated low-cost model for a tool-started live lifecycle.
49
+
50
+ Run `npm run check`, `npm run test:e2e`,
51
+ `npx slophammer-ts@latest dry .`, and
52
+ `npx slophammer-ts@latest check . --only ts.dependency-boundaries-required`.
53
+ Run SimpleDoc and relevant Rust viewer checks. Existing unrelated documentation
54
+ issues must be identified, not silently repaired. Push before running
55
+ `pi-reviewer --base main`; address P0/P1 findings before checking CI and merging.
56
+
57
+ ## Completion evidence
58
+
59
+ Implemented in PR #85. Local validation passed with 1,215 unit tests, 13 real-Pi
60
+ mock-provider E2E tests, and 76 Rust tests. Slophammer, Clippy, and format checks
61
+ passed. SimpleDoc reports the existing nine naming/frontmatter issues and 24
62
+ reference updates.
63
+
64
+ The final code commit passed the authenticated live test with
65
+ `openrouter/deepseek/deepseek-v4-flash`, Pi 0.85.0, and a 4,096-token output
66
+ allowance. It verified ordinary chat, a model tool start, exact submission,
67
+ next-step completion, and saved conversation capture. The isolated Autoimplement
68
+ test also verified actual temporary worktree creation before cancellation.
69
+
70
+ The first reviewer attempt stopped on an unsupported local provider configuration.
71
+ After that configuration was repaired, the configured reviewer found a P1 recovery
72
+ case: a delivered step without an active workflow turn could claim an ordinary
73
+ chat response. Recovery now requires the recorded active turn and its exact
74
+ message as the latest user or custom input. Regression tests cover missing turn
75
+ records and later ordinary input. The configured reviewer then completed with no
76
+ findings. All four CI jobs passed; the check job needed an unchanged retry after
77
+ a lease-renewal test timed out. That test also passed locally in the full suite
78
+ and a focused run.
79
+
80
+ [PR #85](https://github.com/osolmaz/pi-workflows/pull/85) was rebase-merged on
81
+ 2026-09-07. The branch was removed. The final live-model run was
82
+ `20260907T033236999Z-live-model-e2e-8c53326e`, with reported cost $0.0004163409.
83
+ No Pi Workflows installation or live run was changed. Recovery of an installed
84
+ live session is separate work and requires approval to update that installation.
@@ -120,6 +120,16 @@ Notifications keep the custom type `pi-workflows-notification` and use `triggerT
120
120
 
121
121
  The extension has one `WorkflowMessageCoordinator` for all message kinds. The server keeps one active coordinator connection and process-local epoch for each origin session. A replacement connection fences the old one, so two Pi processes cannot send for the same session.
122
122
 
123
+ The coordinator retains only workflow-owned turns. Its local state is absent,
124
+ delivering, running, or settled awaiting acknowledgment. An ordinary chat turn
125
+ never creates an owned turn or blocks later workflow delivery. Recovery requires
126
+ an already recorded active workflow turn and its exact message as the latest
127
+ user or custom input in the active Pi branch. A delivered step with no active
128
+ workflow turn cannot claim a later ordinary chat response.
129
+ A delayed acknowledgment retains the same message, turn ID, and exact response;
130
+ it never creates another model attempt. Unconfirmed delivery stays visible as
131
+ unconfirmed instead of being reported as a received step.
132
+
123
133
  The coordinator follows this sequence:
124
134
 
125
135
  1. After every server connection, wait for the complete origin-session view and report the active branch before any send or turn report.
@@ -142,9 +152,10 @@ After Pi, the extension, or the server restarts, branch reporting runs before an
142
152
  ## Model-turn status
143
153
 
144
154
  `agent_start` has no message payload. A locally delivered prompt binds its start
145
- through the coordinator's saved message identity. Late binding and reconnect
146
- also require the exact message in the active branch. A session view alone does
147
- not prove that a message caused the current turn.
155
+ through the coordinator's saved message identity. Reconnect requires the same
156
+ recorded active turn, run, session, and message, with no later user or custom
157
+ input in the active branch. Neither a session view nor an old branch entry alone
158
+ proves that a message caused the current turn.
148
159
 
149
160
  Only pending, unpaused agent requests and explicit follow-ups can open model
150
161
  turns. Decisions, notifications, and terminal notices cannot. A stale start can
package/docs/workflows.md CHANGED
@@ -5,6 +5,23 @@ covers the file format, every node type, edge routing, the step contract the
5
5
  model sees, and how runs behave at runtime. For durable state, see
6
6
  [SQLITE_STATE.md](SQLITE_STATE.md).
7
7
 
8
+ ## Starting and resuming runs
9
+
10
+ A successful start call confirms that a run was created. Wait for the delivered
11
+ step contract before submitting an agent result. Report worktree creation or
12
+ implementation progress only after the corresponding recorded steps succeed.
13
+ The status view distinguishes unconfirmed step delivery, active agent work,
14
+ required results, and a durable pause. Waiting uses a different glyph from pause.
15
+
16
+ Resume uses the run's original source and graph, including ordinary workflows
17
+ without included workflows. Source and graph checks run before any durable
18
+ mutation. `WorkflowEngine.resumeRun` has no `force` option. Restore the original
19
+ source to resume its accepted work, or explicitly start a new run for changed
20
+ code. A new run does not inherit outputs, approvals, or effect receipts.
21
+ File and built-in source identities verify code; callers of the direct engine
22
+ API must supply source identity to detect callback-body changes that leave the
23
+ structural graph unchanged.
24
+
8
25
  ## Workflow files
9
26
 
10
27
  A workflow is a TypeScript module whose default export is `defineWorkflow(...)`.
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "osolmaz.pi-workflows"
2
2
  name = "pi-workflows"
3
- version = "0.16.6"
3
+ version = "0.16.7"
4
4
  min_herdr_version = "0.7.0"
5
5
  description = "Open the active pi-workflows run in piw from a managed Herdr pane."
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osolmaz/pi-workflows",
3
- "version": "0.16.6",
3
+ "version": "0.16.7",
4
4
  "description": "Workflow and resource manager runtime with a live terminal viewer for the pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -850,7 +850,7 @@ async function executeCommand(
850
850
  },
851
851
  });
852
852
  return {
853
- message: `Started hosted workflow ${resolved.workflowName} as ${runId}.`,
853
+ message: `Created hosted workflow ${resolved.workflowName} as ${runId}. This confirms the run, not worktree creation or implementation. Complete the next delivered step using its exact contract.`,
854
854
  details: { action: "start", runId, response: response.receipt ?? null },
855
855
  };
856
856
  }
@@ -54,7 +54,7 @@ export function nodeGlyph(
54
54
  return "◐";
55
55
  }
56
56
  if (state.waitingOn === nodeId) {
57
- return "⏸";
57
+ return displayStatus === "paused" || state.paused === true ? "⏸" : "○";
58
58
  }
59
59
  const result = state.results[nodeId];
60
60
  if (!result) {
@@ -23,14 +23,11 @@ type DeliveryCallbacks = {
23
23
  terminalDelivered?: (message: WorkflowMessage) => Promise<void>;
24
24
  };
25
25
 
26
- type PendingTurn = {
26
+ type OwnedTurn = {
27
27
  workflowTurnId: string;
28
- workflowMessageId: string | null;
29
- runId: string | null;
30
- message: WorkflowMessage | null;
28
+ message: WorkflowMessage;
31
29
  startedReported: boolean;
32
- end: SettledTurn | null;
33
- };
30
+ } & ({ phase: "delivering" | "running" } | { phase: "settled"; end: SettledTurn });
34
31
 
35
32
  /** Adds every server-owned workflow message to one Pi session through one public API path. */
36
33
  export class WorkflowMessageCoordinator {
@@ -40,8 +37,7 @@ export class WorkflowMessageCoordinator {
40
37
  private synchronizing = false;
41
38
  private lastBranchEpoch: string | null = null;
42
39
  private view: WorkflowSessionView | null = null;
43
- private awaitingTurnMessage: WorkflowMessage | null = null;
44
- private turn: PendingTurn | null = null;
40
+ private turn: OwnedTurn | null = null;
45
41
 
46
42
  updateView(view: WorkflowSessionView): void {
47
43
  this.view = view;
@@ -64,24 +60,15 @@ export class WorkflowMessageCoordinator {
64
60
  }
65
61
 
66
62
  startTurn(): void {
67
- // Automatic Pi retries belong to the same unsettled workflow turn.
68
- if (this.turn !== null) return;
69
- const awaited = this.awaitingTurnMessage;
70
- this.awaitingTurnMessage = null;
71
- this.turn = {
72
- workflowTurnId: `workflow-turn-${randomUUID()}`,
73
- workflowMessageId: null,
74
- runId: null,
75
- message: null,
76
- startedReported: false,
77
- end: null,
78
- };
79
- if (awaited !== null) this.bindTurn(awaited);
63
+ // Ordinary chat owns no workflow turn. Automatic Pi retries retain the
64
+ // existing turn, including a settled result whose acknowledgment is pending.
65
+ if (this.turn?.phase === "delivering") this.turn = { ...this.turn, phase: "running" };
80
66
  }
81
67
 
82
68
  endTurn(stopReason: WorkflowTurnStopReason, responseSessionEntryId: string | null): void {
83
- if (this.turn !== null && this.turn.end === null)
84
- this.turn.end = { stopReason, responseSessionEntryId };
69
+ if (this.turn?.phase === "running") {
70
+ this.turn = { ...this.turn, phase: "settled", end: { stopReason, responseSessionEntryId } };
71
+ }
85
72
  }
86
73
 
87
74
  activeTurnMessage(): WorkflowMessage | undefined {
@@ -101,27 +88,25 @@ export class WorkflowMessageCoordinator {
101
88
  const view = this.view;
102
89
  if (!view.coordinatorActive || view.coordinatorEpoch === null) return;
103
90
  const branchEntries = branchWorkflowEntries(ctx.sessionManager.getBranch());
104
- if (this.turn?.workflowMessageId === null) {
91
+ if (this.turn === null && !ctx.isIdle()) {
105
92
  const candidate = this.turnCandidate();
106
- if (candidate !== undefined && branchEntries.has(candidate.workflowMessageId)) {
107
- this.bindTurn(candidate);
93
+ const open = view.openWorkflowTurn;
94
+ if (
95
+ candidate !== undefined &&
96
+ open?.state === "started" &&
97
+ open.workflowMessageId === candidate.workflowMessageId &&
98
+ open.runId === candidate.runId &&
99
+ open.targetSessionId === view.sessionId &&
100
+ latestTurnInputIsWorkflow(ctx.sessionManager.getBranch(), candidate.workflowMessageId)
101
+ ) {
102
+ this.turn = {
103
+ workflowTurnId: open.workflowTurnId,
104
+ message: candidate,
105
+ startedReported: true,
106
+ phase: "running",
107
+ };
108
108
  }
109
109
  }
110
- if (
111
- this.turn === null &&
112
- !ctx.isIdle() &&
113
- view.openWorkflowTurn !== null &&
114
- branchEntries.has(view.openWorkflowTurn.workflowMessageId)
115
- ) {
116
- this.turn = {
117
- workflowTurnId: view.openWorkflowTurn.workflowTurnId,
118
- workflowMessageId: view.openWorkflowTurn.workflowMessageId,
119
- runId: view.openWorkflowTurn.runId,
120
- message: messageById(view, view.openWorkflowTurn.workflowMessageId) ?? null,
121
- startedReported: true,
122
- end: null,
123
- };
124
- }
125
110
  if (
126
111
  view.branchReportRequired ||
127
112
  this.lastBranchEpoch !== view.coordinatorEpoch ||
@@ -170,7 +155,12 @@ export class WorkflowMessageCoordinator {
170
155
  return;
171
156
  }
172
157
  if (messageStartsTurn(message)) {
173
- this.awaitingTurnMessage = message;
158
+ this.turn = {
159
+ workflowTurnId: `workflow-turn-${randomUUID()}`,
160
+ message,
161
+ startedReported: false,
162
+ phase: "delivering",
163
+ };
174
164
  }
175
165
  try {
176
166
  pi.sendMessage(
@@ -184,8 +174,11 @@ export class WorkflowMessageCoordinator {
184
174
  );
185
175
  } catch (error) {
186
176
  this.queued.delete(messageId);
187
- if (this.awaitingTurnMessage?.workflowMessageId === messageId) {
188
- this.awaitingTurnMessage = null;
177
+ if (
178
+ this.turn?.phase === "delivering" &&
179
+ this.turn.message.workflowMessageId === messageId
180
+ ) {
181
+ this.turn = null;
189
182
  }
190
183
  throw error;
191
184
  }
@@ -201,7 +194,6 @@ export class WorkflowMessageCoordinator {
201
194
  this.closedTurnMessages.clear();
202
195
  this.finalizedTerminals.clear();
203
196
  this.view = null;
204
- this.awaitingTurnMessage = null;
205
197
  this.turn = null;
206
198
  this.lastBranchEpoch = null;
207
199
  this.synchronizing = false;
@@ -227,23 +219,15 @@ export class WorkflowMessageCoordinator {
227
219
  return undefined;
228
220
  }
229
221
 
230
- private bindTurn(message: WorkflowMessage): void {
231
- if (this.turn === null) return;
232
- this.turn.workflowMessageId = message.workflowMessageId;
233
- this.turn.runId = message.runId;
234
- this.turn.message = message;
235
- }
236
-
237
222
  private async flushTurn(
238
223
  client: WorkflowClient,
239
224
  view: WorkflowSessionView,
240
225
  beforeTurnEnd?: BeforeTurnEnd,
241
226
  ): Promise<void> {
242
- const pending = this.turn;
227
+ let pending = this.turn;
243
228
  if (
244
229
  pending === null ||
245
- pending.workflowMessageId === null ||
246
- pending.runId === null ||
230
+ pending.phase === "delivering" ||
247
231
  view.coordinatorEpoch === null ||
248
232
  this.lastBranchEpoch !== view.coordinatorEpoch
249
233
  ) {
@@ -251,19 +235,24 @@ export class WorkflowMessageCoordinator {
251
235
  }
252
236
  let message = pending.message;
253
237
  if (!pending.startedReported) {
254
- message = messageById(view, pending.workflowMessageId) ?? null;
255
- if (message?.status !== "sent") return;
238
+ const confirmed = messageById(view, message.workflowMessageId);
239
+ if (confirmed?.status !== "sent") return;
240
+ message = confirmed;
256
241
  const receipt = await reportTurn(client, {
257
242
  state: "started",
258
- workflowMessageId: pending.workflowMessageId,
243
+ workflowMessageId: message.workflowMessageId,
259
244
  workflowTurnId: pending.workflowTurnId,
260
- runId: pending.runId,
245
+ runId: message.runId,
261
246
  targetSessionId: view.sessionId,
262
247
  coordinatorEpoch: view.coordinatorEpoch,
263
248
  });
249
+ // Pi can settle while the start report is in flight. Keep that exact
250
+ // response and do not restore a turn cleared by session shutdown.
251
+ if (this.turn?.workflowTurnId !== pending.workflowTurnId) return;
252
+ pending = this.turn;
264
253
  if (
265
254
  receipt.ownership !== "active" &&
266
- !(receipt.ownership === "settled" && pending.end !== null)
255
+ !(receipt.ownership === "settled" && pending.phase === "settled")
267
256
  ) {
268
257
  this.turn = null;
269
258
  return;
@@ -271,28 +260,28 @@ export class WorkflowMessageCoordinator {
271
260
  pending.message = message;
272
261
  pending.startedReported = true;
273
262
  }
274
- if (pending.end === null) return;
275
- if (message !== null) await beforeTurnEnd?.(message, pending.end);
263
+ if (pending.phase !== "settled") return;
264
+ await beforeTurnEnd?.(message, pending.end);
276
265
  await reportTurn(client, {
277
266
  state: "ended",
278
- workflowMessageId: pending.workflowMessageId,
267
+ workflowMessageId: message.workflowMessageId,
279
268
  workflowTurnId: pending.workflowTurnId,
280
- runId: pending.runId,
269
+ runId: message.runId,
281
270
  targetSessionId: view.sessionId,
282
271
  coordinatorEpoch: view.coordinatorEpoch,
283
272
  stopReason: pending.end.stopReason,
284
273
  responseSessionEntryId: pending.end.responseSessionEntryId,
285
274
  });
286
- if (message?.kind === "followUp") {
287
- this.closedTurnMessages.add(pending.workflowMessageId);
275
+ if (message.kind === "followUp") {
276
+ this.closedTurnMessages.add(message.workflowMessageId);
288
277
  }
289
278
  for (const current of new Set([view, this.view])) {
290
279
  if (current?.openWorkflowTurn?.workflowTurnId === pending.workflowTurnId) {
291
280
  current.openWorkflowTurn = null;
292
281
  }
293
282
  if (
294
- current?.openWorkflowMessageId === pending.workflowMessageId &&
295
- message?.kind === "followUp"
283
+ current?.openWorkflowMessageId === message.workflowMessageId &&
284
+ message.kind === "followUp"
296
285
  ) {
297
286
  current.openWorkflowMessageId = null;
298
287
  }
@@ -360,6 +349,27 @@ export function branchWorkflowEntries(entries: readonly unknown[]): Map<string,
360
349
  return found;
361
350
  }
362
351
 
352
+ function latestTurnInputIsWorkflow(
353
+ entries: readonly unknown[],
354
+ workflowMessageId: string,
355
+ ): boolean {
356
+ for (const value of [...entries].reverse()) {
357
+ if (!isRecord(value)) continue;
358
+ if (
359
+ value.role === "user" ||
360
+ (value.type === "message" && isRecord(value.message) && value.message.role === "user")
361
+ ) {
362
+ return false;
363
+ }
364
+ if (value.type === "custom_message" || value.role === "custom") {
365
+ return (
366
+ isRecord(value.details) && value.details[WORKFLOW_MESSAGE_ID_FIELD] === workflowMessageId
367
+ );
368
+ }
369
+ }
370
+ return false;
371
+ }
372
+
363
373
  export function responseEntryId(entries: readonly unknown[]): string | null {
364
374
  for (const value of [...entries].reverse()) {
365
375
  if (!isRecord(value) || typeof value.id !== "string") continue;
@@ -48,7 +48,7 @@ const STATUS_GLYPHS: Record<NodeStatus, string> = {
48
48
  timed_out: "×",
49
49
  active: "◐",
50
50
  replay_focus: "◆",
51
- waiting: "",
51
+ waiting: "",
52
52
  cancelled: "~",
53
53
  queued: "·",
54
54
  };
@@ -774,6 +774,7 @@ export class ServerViewStore {
774
774
  runnerActive: this.hasLiveRunner(queue.runId),
775
775
  originTurnActive: this.hasActivity(queue.runId),
776
776
  pendingRequestKind: this.pendingRequestKind(queue.runId),
777
+ requestDeliveryConfirmed: this.requestDeliveryConfirmed(queue.runId),
777
778
  errorMessage: state?.error ?? queue.errorMessage,
778
779
  });
779
780
  }
@@ -803,6 +804,18 @@ export class ServerViewStore {
803
804
  return row?.kind ?? null;
804
805
  }
805
806
 
807
+ private requestDeliveryConfirmed(runId: string): boolean {
808
+ const row = this.state.connection
809
+ .prepare(
810
+ `SELECT m.status FROM interactive_requests i
811
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
812
+ WHERE i.run_id = ? AND i.status = 'pending'
813
+ ORDER BY m.order_number DESC LIMIT 1`,
814
+ )
815
+ .get(runId) as { status: string } | undefined;
816
+ return row?.status === "sent";
817
+ }
818
+
806
819
  private hasAmbiguousEffect(runId: string): boolean {
807
820
  const row = this.state.connection
808
821
  .prepare(
@@ -836,6 +849,7 @@ export class ServerViewStore {
836
849
  this.hasLiveRunner(runId),
837
850
  this.hasActivity(runId),
838
851
  this.pendingRequestKind(runId),
852
+ this.requestDeliveryConfirmed(runId),
839
853
  this.hasAmbiguousEffect(runId),
840
854
  ].join(":");
841
855
  }
@@ -869,6 +883,7 @@ export type WorkflowDisplayFacts = {
869
883
  runnerActive: boolean;
870
884
  originTurnActive: boolean;
871
885
  pendingRequestKind: "agent" | "assistant" | "checkpoint" | "decision" | null;
886
+ requestDeliveryConfirmed: boolean;
872
887
  errorMessage: string | null;
873
888
  };
874
889
 
@@ -909,6 +924,10 @@ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisp
909
924
  : facts.pendingRequestKind === "assistant"
910
925
  ? "The workflow needs its assigned visible response."
911
926
  : "The workflow is waiting.";
927
+ if (facts.pendingRequestKind === "agent" || facts.pendingRequestKind === "assistant") {
928
+ if (facts.originTurnActive) reason = "The agent is working on the workflow step.";
929
+ else if (!facts.requestDeliveryConfirmed) reason = "Workflow step delivery is not confirmed.";
930
+ }
912
931
  } else if (activity !== null) {
913
932
  status = "running";
914
933
  } else if (facts.queueStatus === "parked" || facts.queueStatus === "queued") {
@@ -329,7 +329,6 @@ export class WorkflowEngine {
329
329
  runId: string,
330
330
  options: {
331
331
  workflowSource?: WorkflowSource;
332
- force?: boolean;
333
332
  resumeInteractionAttemptId?: string;
334
333
  } = {},
335
334
  ): Promise<WorkflowRunResult> {
@@ -343,7 +342,7 @@ export class WorkflowEngine {
343
342
  const stored = await this.store.readRunState(runId);
344
343
  if (stored === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
345
344
  const sourceMismatch = workflowIdentityMismatch(stored, workflow, options.workflowSource);
346
- if (sourceMismatch && options.force !== true) {
345
+ if (sourceMismatch) {
347
346
  throw new WorkflowSourceChangedError(runId);
348
347
  }
349
348
  const state = await this.store.prepareRunResume(runId);
@@ -395,7 +394,6 @@ export class WorkflowEngine {
395
394
  ...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
396
395
  ...(resumedAttempt !== undefined ? { resumedAttemptId: resumedAttempt.attemptId } : {}),
397
396
  replayedSteps: state.steps.length,
398
- ...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
399
397
  },
400
398
  });
401
399
  await this.onRunStarted?.(runId, state);
@@ -533,9 +531,7 @@ export class WorkflowEngine {
533
531
  ...(await this.resolveTitleBounded(workflow, input)),
534
532
  ...(workflowSource !== undefined ? { workflowSource } : {}),
535
533
  ...(composition?.sources.length ? { workflowSources: composition.sources } : {}),
536
- ...(composition?.snapshot.mounts.length
537
- ? { definitionDigest: definitionDigest(workflow) }
538
- : {}),
534
+ definitionDigest: definitionDigest(workflow),
539
535
  startedAt: now,
540
536
  updatedAt: now,
541
537
  status: "running",
@@ -1778,8 +1774,7 @@ export function workflowIdentityMismatch(
1778
1774
  const metadata = compositionMetadata(workflow);
1779
1775
  const currentSources = metadata?.sources ?? [];
1780
1776
  if (!isDeepStrictEqual(state.workflowSources ?? [], currentSources)) return true;
1781
- const currentDigest = metadata?.snapshot.mounts.length ? definitionDigest(workflow) : undefined;
1782
- return state.definitionDigest !== currentDigest;
1777
+ return state.definitionDigest !== definitionDigest(workflow);
1783
1778
  }
1784
1779
 
1785
1780
  function definitionDigest(workflow: WorkflowDefinition): string {
@@ -54,12 +54,14 @@ export function isRunParkedError(error: unknown): error is RunParkedError {
54
54
  return error instanceof RunParkedError;
55
55
  }
56
56
 
57
- /** The workflow source changed after the run started; resume needs force. */
57
+ /** A run can resume only with its original workflow source and definition. */
58
58
  export class WorkflowSourceChangedError extends Error {
59
59
  readonly runId: string;
60
60
 
61
61
  constructor(runId: string) {
62
- super(`Workflow source changed since run ${runId} started; pass force to resume anyway`);
62
+ super(
63
+ `Workflow source or definition changed since run ${runId} started; restore the original definition to resume, or explicitly start a new run`,
64
+ );
63
65
  this.name = "WorkflowSourceChangedError";
64
66
  this.runId = runId;
65
67
  }
@@ -19,7 +19,7 @@ import {
19
19
  type ViewerDeltaDraft,
20
20
  } from "../state/viewer.js";
21
21
  import { WorkflowMessageStore, workflowMessageIdFor } from "../state/workflow-messages.js";
22
- import { compositionMetadata } from "./composition.js";
22
+ import { compileWorkflowDefinition, compositionMetadata } from "./composition.js";
23
23
  import { ClaimLostError } from "./errors.js";
24
24
  import { HumanDecisionStore } from "./human-decision.js";
25
25
  import { applyJsonPatch, validateJsonPatch } from "./json-patch.js";
@@ -844,7 +844,7 @@ export class WorkflowRunStore {
844
844
  options: InitializeWorkflowRunOptions = {},
845
845
  ): Promise<string> {
846
846
  return await this.initializeRunFromSnapshot(
847
- createDefinitionSnapshot(workflow),
847
+ createDefinitionSnapshot(compileWorkflowDefinition(workflow)),
848
848
  workflow.name,
849
849
  state,
850
850
  options,
@@ -3608,9 +3608,7 @@ export class WorkflowRunStore {
3608
3608
  ...(row.title === null ? {} : { runTitle: row.title }),
3609
3609
  ...(sources.root === undefined ? {} : { workflowSource: sources.root }),
3610
3610
  ...(sources.mounted.length === 0 ? {} : { workflowSources: sources.mounted }),
3611
- ...(sources.mounted.length !== 0 || snapshot.composition?.mounts.length
3612
- ? { definitionDigest: `sha256:${row.definitionDigest.toString("hex")}` }
3613
- : {}),
3611
+ definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
3614
3612
  startedAt: new Date(row.createdAt).toISOString(),
3615
3613
  ...(row.finishedAt === null ? {} : { finishedAt: new Date(row.finishedAt).toISOString() }),
3616
3614
  updatedAt: new Date(row.updatedAt).toISOString(),