@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.
- package/dist/client/view.d.ts +6 -0
- package/dist/client/view.js +1 -0
- package/dist/client/view.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +8 -0
- package/dist/controllers/sqlite.js +54 -1
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +34 -15
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/workflow-message-coordinator.d.ts +3 -2
- package/dist/extension/workflow-message-coordinator.js +78 -26
- package/dist/extension/workflow-message-coordinator.js.map +1 -1
- package/dist/host/runner.d.ts +8 -1
- package/dist/host/runner.js +231 -176
- package/dist/host/runner.js.map +1 -1
- package/dist/host/view.js +6 -2
- package/dist/host/view.js.map +1 -1
- package/dist/host/worker-entry.d.ts +4 -1
- package/dist/host/worker-entry.js +23 -24
- package/dist/host/worker-entry.js.map +1 -1
- package/dist/host/worker-protocol.d.ts +15 -0
- package/dist/host/worker-protocol.js.map +1 -1
- package/dist/state/workflow-messages.d.ts +2 -0
- package/dist/state/workflow-messages.js +31 -0
- package/dist/state/workflow-messages.js.map +1 -1
- package/dist/workflows/composition.js +0 -4
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.js +0 -8
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.js +4 -5
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +15 -0
- package/dist/workflows/store.js +42 -0
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +1 -3
- package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
- package/docs/SQLITE_STATE.md +8 -4
- package/docs/WORKFLOW_HOST.md +14 -5
- package/docs/workflows.md +10 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/client/view.ts +8 -0
- package/src/controllers/sqlite.ts +83 -1
- package/src/extension/index.ts +37 -19
- package/src/extension/workflow-message-coordinator.ts +91 -27
- package/src/host/runner.ts +277 -228
- package/src/host/view.ts +6 -2
- package/src/host/worker-entry.ts +38 -30
- package/src/host/worker-protocol.ts +11 -0
- package/src/state/workflow-messages.ts +51 -0
- package/src/workflows/composition.ts +0 -5
- package/src/workflows/definition.ts +0 -8
- package/src/workflows/engine.ts +4 -6
- package/src/workflows/schema.ts +0 -6
- package/src/workflows/store.ts +64 -0
- 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
|
|
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,
|
package/src/extension/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this.turn = null;
|
|
256
|
+
throw error;
|
|
210
257
|
}
|
|
211
|
-
if (
|
|
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
|
|
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(
|
|
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> {
|