@osolmaz/pi-workflows 0.16.1 → 0.16.2

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 (57) hide show
  1. package/dist/client/view.d.ts +6 -0
  2. package/dist/client/view.js +1 -0
  3. package/dist/client/view.js.map +1 -1
  4. package/dist/controllers/sqlite.d.ts +8 -0
  5. package/dist/controllers/sqlite.js +54 -1
  6. package/dist/controllers/sqlite.js.map +1 -1
  7. package/dist/extension/index.js +34 -15
  8. package/dist/extension/index.js.map +1 -1
  9. package/dist/extension/workflow-message-coordinator.d.ts +3 -2
  10. package/dist/extension/workflow-message-coordinator.js +78 -26
  11. package/dist/extension/workflow-message-coordinator.js.map +1 -1
  12. package/dist/host/runner.d.ts +8 -1
  13. package/dist/host/runner.js +231 -176
  14. package/dist/host/runner.js.map +1 -1
  15. package/dist/host/view.js +6 -2
  16. package/dist/host/view.js.map +1 -1
  17. package/dist/host/worker-entry.d.ts +4 -1
  18. package/dist/host/worker-entry.js +23 -24
  19. package/dist/host/worker-entry.js.map +1 -1
  20. package/dist/host/worker-protocol.d.ts +15 -0
  21. package/dist/host/worker-protocol.js.map +1 -1
  22. package/dist/state/workflow-messages.d.ts +2 -0
  23. package/dist/state/workflow-messages.js +31 -0
  24. package/dist/state/workflow-messages.js.map +1 -1
  25. package/dist/workflows/composition.js +0 -4
  26. package/dist/workflows/composition.js.map +1 -1
  27. package/dist/workflows/definition.js +0 -8
  28. package/dist/workflows/definition.js.map +1 -1
  29. package/dist/workflows/engine.js +4 -5
  30. package/dist/workflows/engine.js.map +1 -1
  31. package/dist/workflows/schema.js +0 -4
  32. package/dist/workflows/schema.js.map +1 -1
  33. package/dist/workflows/store.d.ts +15 -0
  34. package/dist/workflows/store.js +42 -0
  35. package/dist/workflows/store.js.map +1 -1
  36. package/dist/workflows/types.d.ts +1 -3
  37. package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
  38. package/docs/SQLITE_STATE.md +8 -4
  39. package/docs/WORKFLOW_HOST.md +14 -5
  40. package/docs/workflows.md +10 -1
  41. package/herdr-plugin.toml +1 -1
  42. package/package.json +1 -1
  43. package/src/client/view.ts +8 -0
  44. package/src/controllers/sqlite.ts +83 -1
  45. package/src/extension/index.ts +37 -19
  46. package/src/extension/workflow-message-coordinator.ts +91 -27
  47. package/src/host/runner.ts +277 -228
  48. package/src/host/view.ts +6 -2
  49. package/src/host/worker-entry.ts +38 -30
  50. package/src/host/worker-protocol.ts +11 -0
  51. package/src/state/workflow-messages.ts +51 -0
  52. package/src/workflows/composition.ts +0 -5
  53. package/src/workflows/definition.ts +0 -8
  54. package/src/workflows/engine.ts +4 -6
  55. package/src/workflows/schema.ts +0 -6
  56. package/src/workflows/store.ts +64 -0
  57. package/src/workflows/types.ts +1 -3
@@ -5,6 +5,7 @@ import { StateDatabase, workflowStatePath } from "../state/database.js";
5
5
  import { canonicalJson } from "../state/json.js";
6
6
  import { resourceIdFor, tokenHash } from "../state/mutation.js";
7
7
  import { initializeViewerRun, recordViewerDeltas } from "../state/viewer.js";
8
+ import { WorkflowMessageStore } from "../state/workflow-messages.js";
8
9
  import {
9
10
  EffectRequestConflictError,
10
11
  ResourceConflictError,
@@ -270,6 +271,7 @@ type WorkflowRow = {
270
271
  export class SqliteControllerStore implements ControllerStore {
271
272
  readonly filePath: string;
272
273
  readonly state: StateDatabase;
274
+ private readonly workflowMessages: WorkflowMessageStore;
273
275
  private readonly ownsState: boolean;
274
276
  private readonly projectId: string | null;
275
277
  private closed = false;
@@ -293,6 +295,7 @@ export class SqliteControllerStore implements ControllerStore {
293
295
  checkLegacyState: filePath === workflowStatePath(),
294
296
  });
295
297
  this.filePath = this.state.filePath;
298
+ this.workflowMessages = new WorkflowMessageStore(this.state);
296
299
  if (options.global === true) {
297
300
  this.projectId = null;
298
301
  } else {
@@ -1556,7 +1559,7 @@ export class SqliteControllerStore implements ControllerStore {
1556
1559
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
1557
1560
  "NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
1558
1561
  "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status = 'pending')",
1559
- "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
1562
+ "(q.error_code IS NULL OR q.error_code NOT IN ('workflowSourceChanged', 'workerNoProgress'))",
1560
1563
  ];
1561
1564
  const params: unknown[] = [now, now, options.runnerId, options.runnerId];
1562
1565
  if (this.projectId !== null) {
@@ -1669,6 +1672,78 @@ export class SqliteControllerStore implements ControllerStore {
1669
1672
  return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
1670
1673
  }
1671
1674
 
1675
+ parkWorkflowRunForWorkerNoProgress(options: {
1676
+ runId: string;
1677
+ claimToken: string;
1678
+ detail: string;
1679
+ now?: string;
1680
+ }): boolean {
1681
+ const now = epoch(validTimestamp(options.now));
1682
+ return this.state.transaction(() => {
1683
+ if (
1684
+ !this.verifyWorkflowRunClaim({
1685
+ runId: options.runId,
1686
+ claimToken: options.claimToken,
1687
+ now: new Date(now).toISOString(),
1688
+ })
1689
+ ) {
1690
+ return false;
1691
+ }
1692
+ const row = this.requireWorkflowRunRow(options.runId);
1693
+ const lease = this.requireLease(row.resourceId);
1694
+ const detail = options.detail.slice(0, 8_192);
1695
+ const errorHash = this.state.putText(detail, now);
1696
+ const run = this.state.connection
1697
+ .prepare(
1698
+ `UPDATE runs SET status_detail = ?, updated_at = ?, finished_at = NULL
1699
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`,
1700
+ )
1701
+ .run(detail, now, options.runId);
1702
+ const queue = this.state.connection
1703
+ .prepare(
1704
+ `UPDATE run_queue
1705
+ SET status = 'parked', error_code = 'workerNoProgress', error_hash = ?,
1706
+ available_at = ?, updated_at = ?, finished_at = NULL
1707
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running', 'parked')`,
1708
+ )
1709
+ .run(errorHash, now, now, options.runId);
1710
+ if (run.changes !== 1 || queue.changes !== 1) {
1711
+ throw new Error(`Workflow run ${options.runId} changed during worker recovery`);
1712
+ }
1713
+ const released = this.state.connection
1714
+ .prepare(
1715
+ `UPDATE leases
1716
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1717
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1718
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`,
1719
+ )
1720
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
1721
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
1722
+ if (released.changes !== 1) {
1723
+ throw new Error(`Workflow run ${options.runId} claim changed during worker recovery`);
1724
+ }
1725
+ const revision = this.resourceRevision(row.resourceId);
1726
+ this.bumpResource(row.resourceId, revision, now);
1727
+ this.insertEvent(
1728
+ row.resourceId,
1729
+ revision + 1,
1730
+ "run.worker_no_progress",
1731
+ lease.ownerType ?? "system",
1732
+ lease.ownerId,
1733
+ { status: "parked", code: "workerNoProgress" },
1734
+ now,
1735
+ lease.generation || undefined,
1736
+ );
1737
+ recordViewerDeltas(
1738
+ this.state,
1739
+ options.runId,
1740
+ [{ targetType: "summary" }, { targetType: "replay" }],
1741
+ now,
1742
+ );
1743
+ return true;
1744
+ });
1745
+ }
1746
+
1672
1747
  pauseParkedWorkflowRun(options: { runId: string; now?: string }): boolean {
1673
1748
  const now = epoch(validTimestamp(options.now));
1674
1749
  return this.state.transaction(() => {
@@ -2015,6 +2090,7 @@ export class SqliteControllerStore implements ControllerStore {
2015
2090
  if (run.changes !== 1) {
2016
2091
  throw new Error(`Workflow run ${options.runId} has inconsistent durable state`);
2017
2092
  }
2093
+ this.settleWorkflowRunMessages(options.runId, now);
2018
2094
  this.cancelWorkflowRunDependents(options.runId, controlId, errorHash, now);
2019
2095
 
2020
2096
  const released = this.state.connection
@@ -2924,6 +3000,7 @@ export class SqliteControllerStore implements ControllerStore {
2924
3000
  WHERE run_id = ?`,
2925
3001
  )
2926
3002
  .run(status, errorHash, now, now, runId);
3003
+ this.settleWorkflowRunMessages(runId, now);
2927
3004
  if (status === "cancelled") {
2928
3005
  this.cancelWorkflowRunDependents(
2929
3006
  runId,
@@ -2953,6 +3030,11 @@ export class SqliteControllerStore implements ControllerStore {
2953
3030
  });
2954
3031
  }
2955
3032
 
3033
+ private settleWorkflowRunMessages(runId: string, now: number): void {
3034
+ this.workflowMessages.settleOpenTurnsForRun(runId, "lost", now);
3035
+ this.workflowMessages.cancelPendingForRun(runId, now);
3036
+ }
3037
+
2956
3038
  private cancelWorkflowRunDependents(
2957
3039
  runId: string,
2958
3040
  actorId: string,
@@ -183,7 +183,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
183
183
  const workflowMessages = new WorkflowMessageCoordinator();
184
184
  const sessionView = new SessionWorkflowView();
185
185
  const sessionRecorders = new Map<string, SessionRecorder>();
186
+ let agentRunning = false;
186
187
  let activeRecorder: SessionRecorder | null = null;
188
+ let activeRecorderMessageId: string | null = null;
187
189
 
188
190
  const ensureRecorder = async (
189
191
  message: WorkflowMessage,
@@ -203,6 +205,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
203
205
  return recorder;
204
206
  };
205
207
 
208
+ const activateRecorder = async (ctx: ExtensionContext): Promise<void> => {
209
+ if (!agentRunning) return;
210
+ const message = workflowMessages.activeTurnMessage();
211
+ if (message === undefined || activeRecorderMessageId === message.workflowMessageId) return;
212
+ const contract = agentContractForWorkflowMessage(message);
213
+ if (contract === undefined && message.kind !== "terminal" && message.kind !== "followUp") {
214
+ return;
215
+ }
216
+ const recorder = await ensureRecorder(message, ctx);
217
+ if (
218
+ !agentRunning ||
219
+ workflowMessages.activeTurnMessage()?.workflowMessageId !== message.workflowMessageId
220
+ ) {
221
+ return;
222
+ }
223
+ if (contract === undefined) {
224
+ recorder.beginWorkflowMessage(
225
+ message.workflowMessageId,
226
+ message.kind as "terminal" | "followUp",
227
+ );
228
+ } else {
229
+ recorder.beginAttempt(contract);
230
+ }
231
+ activeRecorder = recorder;
232
+ activeRecorderMessageId = message.workflowMessageId;
233
+ };
234
+
206
235
  const presentInOrder = async (ctx: ExtensionContext): Promise<void> => {
207
236
  const prior = presentationTail;
208
237
  let release: (() => void) | undefined;
@@ -212,6 +241,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
212
241
  await prior;
213
242
  try {
214
243
  await workflowMessages.synchronize(pi, client, ctx);
244
+ await activateRecorder(ctx);
215
245
  } finally {
216
246
  sessionView.refresh(ctx);
217
247
  release?.();
@@ -651,27 +681,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
651
681
  });
652
682
 
653
683
  pi.on("agent_start", async (_event, ctx) => {
684
+ agentRunning = true;
685
+ activeRecorder = null;
686
+ activeRecorderMessageId = null;
654
687
  workflowMessages.startTurn();
655
688
  await presentInOrder(ctx).catch(() => undefined);
656
- const message = workflowMessages.activeTurnMessage();
657
- const contract = message === undefined ? undefined : agentContractForWorkflowMessage(message);
658
- if (
659
- message === undefined ||
660
- (contract === undefined && message.kind !== "terminal" && message.kind !== "followUp")
661
- ) {
662
- activeRecorder = null;
663
- return;
664
- }
665
- const recorder = await ensureRecorder(message, ctx);
666
- if (contract === undefined) {
667
- recorder.beginWorkflowMessage(
668
- message.workflowMessageId,
669
- message.kind as "terminal" | "followUp",
670
- );
671
- } else {
672
- recorder.beginAttempt(contract);
673
- }
674
- activeRecorder = recorder;
675
689
  });
676
690
 
677
691
  pi.on("agent_end", async (event, ctx) => {
@@ -681,6 +695,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
681
695
  ctx.ui.notify(`Workflow response was rejected: ${errorMessage(error)}`, "error");
682
696
  }
683
697
  const finishedMessage = workflowMessages.activeTurnMessage();
698
+ agentRunning = false;
699
+ activeRecorderMessageId = null;
684
700
  workflowMessages.endTurn(
685
701
  workflowTurnStopReason(event.messages, ctx.signal?.aborted === true),
686
702
  responseEntryId(ctx.sessionManager.getBranch()),
@@ -747,7 +763,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
747
763
  pi.on("session_shutdown", async (_event, ctx) => {
748
764
  sessionGeneration += 1;
749
765
  sessionContext = null;
766
+ agentRunning = false;
750
767
  activeRecorder = null;
768
+ activeRecorderMessageId = null;
751
769
  await Promise.allSettled(
752
770
  [...sessionRecorders.values()].map(async (recorder) => recorder.stop()),
753
771
  );
@@ -1,9 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { WorkflowClient } from "../client/client.js";
4
- import type { WorkflowSessionView, WorkflowTurnReport } from "../client/view.js";
4
+ import {
5
+ WORKFLOW_TURN_REPORT_RECEIPT_SCHEMA,
6
+ type WorkflowSessionView,
7
+ type WorkflowTurnReport,
8
+ type WorkflowTurnReportReceipt,
9
+ } from "../client/view.js";
5
10
  import type { JsonValue } from "../state/json.js";
6
- import type { WorkflowMessage, WorkflowTurnStopReason } from "../state/workflow-messages.js";
11
+ import {
12
+ WORKFLOW_TURN_SCHEMA,
13
+ type WorkflowMessage,
14
+ type WorkflowTurnStopReason,
15
+ } from "../state/workflow-messages.js";
7
16
 
8
17
  const WORKFLOW_MESSAGE_ID_FIELD = "workflowMessageId";
9
18
 
@@ -11,6 +20,7 @@ type PendingTurn = {
11
20
  workflowTurnId: string;
12
21
  workflowMessageId: string | null;
13
22
  runId: string | null;
23
+ message: WorkflowMessage | null;
14
24
  startedReported: boolean;
15
25
  end: { stopReason: WorkflowTurnStopReason; responseSessionEntryId: string | null } | null;
16
26
  };
@@ -22,6 +32,7 @@ export class WorkflowMessageCoordinator {
22
32
  private synchronizing = false;
23
33
  private lastBranchEpoch: string | null = null;
24
34
  private view: WorkflowSessionView | null = null;
35
+ private awaitingTurnMessage: WorkflowMessage | null = null;
25
36
  private turn: PendingTurn | null = null;
26
37
 
27
38
  updateView(view: WorkflowSessionView): void {
@@ -50,15 +61,17 @@ export class WorkflowMessageCoordinator {
50
61
  }
51
62
 
52
63
  startTurn(): void {
53
- if (this.turn !== null && this.turn.end === null) return;
64
+ const awaited = this.awaitingTurnMessage;
65
+ this.awaitingTurnMessage = null;
54
66
  this.turn = {
55
67
  workflowTurnId: `workflow-turn-${randomUUID()}`,
56
68
  workflowMessageId: null,
57
69
  runId: null,
70
+ message: null,
58
71
  startedReported: false,
59
72
  end: null,
60
73
  };
61
- const candidate = this.turnCandidate();
74
+ const candidate = awaited ?? this.turnCandidate();
62
75
  if (candidate !== undefined) this.bindTurn(candidate);
63
76
  }
64
77
 
@@ -68,10 +81,8 @@ export class WorkflowMessageCoordinator {
68
81
  }
69
82
 
70
83
  activeTurnMessage(): WorkflowMessage | undefined {
71
- if (this.view === null || this.turn?.workflowMessageId === null || this.turn === null) {
72
- return undefined;
73
- }
74
- return messageById(this.view, this.turn.workflowMessageId);
84
+ if (this.turn === null || !this.turn.startedReported) return undefined;
85
+ return this.turn.message ?? undefined;
75
86
  }
76
87
 
77
88
  async synchronize(
@@ -89,7 +100,8 @@ export class WorkflowMessageCoordinator {
89
100
  workflowTurnId: view.openWorkflowTurn.workflowTurnId,
90
101
  workflowMessageId: view.openWorkflowTurn.workflowMessageId,
91
102
  runId: view.openWorkflowTurn.runId,
92
- startedReported: false,
103
+ message: messageById(view, view.openWorkflowTurn.workflowMessageId) ?? null,
104
+ startedReported: true,
93
105
  end: null,
94
106
  };
95
107
  }
@@ -128,6 +140,9 @@ export class WorkflowMessageCoordinator {
128
140
  this.queued.delete(messageId);
129
141
  return;
130
142
  }
143
+ if (message.content.triggerTurn && messageStartsTurn(message)) {
144
+ this.awaitingTurnMessage = message;
145
+ }
131
146
  try {
132
147
  pi.sendMessage(
133
148
  {
@@ -140,6 +155,9 @@ export class WorkflowMessageCoordinator {
140
155
  );
141
156
  } catch (error) {
142
157
  this.queued.delete(messageId);
158
+ if (this.awaitingTurnMessage?.workflowMessageId === messageId) {
159
+ this.awaitingTurnMessage = null;
160
+ }
143
161
  throw error;
144
162
  }
145
163
  await this.reportBranch(client, ctx, view);
@@ -153,6 +171,7 @@ export class WorkflowMessageCoordinator {
153
171
  this.queued.clear();
154
172
  this.closedTurnMessages.clear();
155
173
  this.view = null;
174
+ this.awaitingTurnMessage = null;
156
175
  this.turn = null;
157
176
  this.lastBranchEpoch = null;
158
177
  this.synchronizing = false;
@@ -182,6 +201,7 @@ export class WorkflowMessageCoordinator {
182
201
  if (this.turn === null) return;
183
202
  this.turn.workflowMessageId = message.workflowMessageId;
184
203
  this.turn.runId = message.runId;
204
+ this.turn.message = message;
185
205
  }
186
206
 
187
207
  private async flushTurn(client: WorkflowClient, view: WorkflowSessionView): Promise<void> {
@@ -195,31 +215,47 @@ export class WorkflowMessageCoordinator {
195
215
  ) {
196
216
  return;
197
217
  }
198
- const message = messageById(view, pending.workflowMessageId);
199
- if (message?.status !== "sent") return;
218
+ let message = pending.message;
200
219
  if (!pending.startedReported) {
220
+ message = messageById(view, pending.workflowMessageId) ?? null;
221
+ if (message?.status !== "sent") return;
222
+ try {
223
+ const receipt = await reportTurn(client, {
224
+ state: "started",
225
+ workflowMessageId: pending.workflowMessageId,
226
+ workflowTurnId: pending.workflowTurnId,
227
+ runId: pending.runId,
228
+ targetSessionId: view.sessionId,
229
+ coordinatorEpoch: view.coordinatorEpoch,
230
+ });
231
+ if (receipt.ownership !== "active") {
232
+ this.turn = null;
233
+ return;
234
+ }
235
+ pending.message = message;
236
+ pending.startedReported = true;
237
+ } catch (error) {
238
+ this.turn = null;
239
+ throw error;
240
+ }
241
+ }
242
+ if (pending.end === null) return;
243
+ try {
201
244
  await reportTurn(client, {
202
- state: "started",
245
+ state: "ended",
203
246
  workflowMessageId: pending.workflowMessageId,
204
247
  workflowTurnId: pending.workflowTurnId,
205
248
  runId: pending.runId,
206
249
  targetSessionId: view.sessionId,
207
250
  coordinatorEpoch: view.coordinatorEpoch,
251
+ stopReason: pending.end.stopReason,
252
+ responseSessionEntryId: pending.end.responseSessionEntryId,
208
253
  });
209
- pending.startedReported = true;
254
+ } catch (error) {
255
+ this.turn = null;
256
+ throw error;
210
257
  }
211
- if (pending.end === null) return;
212
- await reportTurn(client, {
213
- state: "ended",
214
- workflowMessageId: pending.workflowMessageId,
215
- workflowTurnId: pending.workflowTurnId,
216
- runId: pending.runId,
217
- targetSessionId: view.sessionId,
218
- coordinatorEpoch: view.coordinatorEpoch,
219
- stopReason: pending.end.stopReason,
220
- responseSessionEntryId: pending.end.responseSessionEntryId,
221
- });
222
- if (message.kind === "terminal" || message.kind === "followUp") {
258
+ if (message?.kind === "terminal" || message?.kind === "followUp") {
223
259
  this.closedTurnMessages.add(pending.workflowMessageId);
224
260
  }
225
261
  for (const current of new Set([view, this.view])) {
@@ -228,7 +264,7 @@ export class WorkflowMessageCoordinator {
228
264
  }
229
265
  if (
230
266
  current?.openWorkflowMessageId === pending.workflowMessageId &&
231
- (message.kind === "terminal" || message.kind === "followUp")
267
+ (message?.kind === "terminal" || message?.kind === "followUp")
232
268
  ) {
233
269
  current.openWorkflowMessageId = null;
234
270
  }
@@ -318,7 +354,10 @@ function messageStartsTurn(message: WorkflowMessage): boolean {
318
354
  return message.kind === "step" || message.kind === "terminal" || message.kind === "followUp";
319
355
  }
320
356
 
321
- async function reportTurn(client: WorkflowClient, report: WorkflowTurnReport): Promise<void> {
357
+ async function reportTurn(
358
+ client: WorkflowClient,
359
+ report: WorkflowTurnReport,
360
+ ): Promise<WorkflowTurnReportReceipt> {
322
361
  const response = await client.request({
323
362
  operation: "workflowTurn.report",
324
363
  runId: report.runId,
@@ -327,6 +366,31 @@ async function reportTurn(client: WorkflowClient, report: WorkflowTurnReport): P
327
366
  if (response.outcome !== "accepted" && response.outcome !== "adopted") {
328
367
  throw new Error(response.error ?? "Workflow host rejected the model-turn report");
329
368
  }
369
+ const receipt = response.receipt;
370
+ if (
371
+ !isRecord(receipt) ||
372
+ receipt.schema !== WORKFLOW_TURN_REPORT_RECEIPT_SCHEMA ||
373
+ !["active", "settled", "absent"].includes(String(receipt.ownership))
374
+ ) {
375
+ throw new Error("Workflow host returned an invalid model-turn receipt");
376
+ }
377
+ const ownership = receipt.ownership as WorkflowTurnReportReceipt["ownership"];
378
+ if (ownership === "absent") {
379
+ if (receipt.turn !== null) {
380
+ throw new Error("Workflow host returned an invalid model-turn receipt");
381
+ }
382
+ } else if (
383
+ !isRecord(receipt.turn) ||
384
+ receipt.turn.schema !== WORKFLOW_TURN_SCHEMA ||
385
+ receipt.turn.workflowTurnId !== report.workflowTurnId ||
386
+ receipt.turn.workflowMessageId !== report.workflowMessageId ||
387
+ receipt.turn.runId !== report.runId ||
388
+ receipt.turn.targetSessionId !== report.targetSessionId ||
389
+ receipt.turn.state !== (ownership === "active" ? "started" : "ended")
390
+ ) {
391
+ throw new Error("Workflow host returned an invalid model-turn receipt");
392
+ }
393
+ return receipt as unknown as WorkflowTurnReportReceipt;
330
394
  }
331
395
 
332
396
  function isRecord(value: unknown): value is Record<string, unknown> {