@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.
- package/dist/extension/index.js +1 -1
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.js +1 -1
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-message-coordinator.d.ts +0 -2
- package/dist/extension/workflow-message-coordinator.js +65 -62
- package/dist/extension/workflow-message-coordinator.js.map +1 -1
- package/dist/render/graph-render.js +1 -1
- package/dist/server/view.d.ts +2 -0
- package/dist/server/view.js +17 -0
- package/dist/server/view.js.map +1 -1
- package/dist/workflows/engine.d.ts +0 -1
- package/dist/workflows/engine.js +3 -7
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +1 -1
- package/dist/workflows/errors.js +2 -2
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/store.js +3 -5
- package/dist/workflows/store.js.map +1 -1
- package/docs/2026-09-07-workflow-handoff-plan.md +84 -0
- package/docs/WORKFLOW_STEP_MESSAGES.md +14 -3
- package/docs/workflows.md +17 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/extension/index.ts +1 -1
- package/src/extension/widget.ts +1 -1
- package/src/extension/workflow-message-coordinator.ts +78 -68
- package/src/render/graph-render.ts +1 -1
- package/src/server/view.ts +19 -0
- package/src/workflows/engine.ts +3 -8
- package/src/workflows/errors.ts +4 -2
- package/src/workflows/store.ts +3 -5
|
@@ -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.
|
|
146
|
-
|
|
147
|
-
|
|
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
package/package.json
CHANGED
package/src/extension/index.ts
CHANGED
|
@@ -850,7 +850,7 @@ async function executeCommand(
|
|
|
850
850
|
},
|
|
851
851
|
});
|
|
852
852
|
return {
|
|
853
|
-
message: `
|
|
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
|
}
|
package/src/extension/widget.ts
CHANGED
|
@@ -23,14 +23,11 @@ type DeliveryCallbacks = {
|
|
|
23
23
|
terminalDelivered?: (message: WorkflowMessage) => Promise<void>;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
type
|
|
26
|
+
type OwnedTurn = {
|
|
27
27
|
workflowTurnId: string;
|
|
28
|
-
|
|
29
|
-
runId: string | null;
|
|
30
|
-
message: WorkflowMessage | null;
|
|
28
|
+
message: WorkflowMessage;
|
|
31
29
|
startedReported: boolean;
|
|
32
|
-
|
|
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
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
84
|
-
this.turn
|
|
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
|
|
91
|
+
if (this.turn === null && !ctx.isIdle()) {
|
|
105
92
|
const candidate = this.turnCandidate();
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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 (
|
|
188
|
-
this.
|
|
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
|
-
|
|
227
|
+
let pending = this.turn;
|
|
243
228
|
if (
|
|
244
229
|
pending === null ||
|
|
245
|
-
pending.
|
|
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
|
-
|
|
255
|
-
if (
|
|
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:
|
|
243
|
+
workflowMessageId: message.workflowMessageId,
|
|
259
244
|
workflowTurnId: pending.workflowTurnId,
|
|
260
|
-
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.
|
|
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.
|
|
275
|
-
|
|
263
|
+
if (pending.phase !== "settled") return;
|
|
264
|
+
await beforeTurnEnd?.(message, pending.end);
|
|
276
265
|
await reportTurn(client, {
|
|
277
266
|
state: "ended",
|
|
278
|
-
workflowMessageId:
|
|
267
|
+
workflowMessageId: message.workflowMessageId,
|
|
279
268
|
workflowTurnId: pending.workflowTurnId,
|
|
280
|
-
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
|
|
287
|
-
this.closedTurnMessages.add(
|
|
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 ===
|
|
295
|
-
message
|
|
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;
|
package/src/server/view.ts
CHANGED
|
@@ -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") {
|
package/src/workflows/engine.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1782
|
-
return state.definitionDigest !== currentDigest;
|
|
1777
|
+
return state.definitionDigest !== definitionDigest(workflow);
|
|
1783
1778
|
}
|
|
1784
1779
|
|
|
1785
1780
|
function definitionDigest(workflow: WorkflowDefinition): string {
|
package/src/workflows/errors.ts
CHANGED
|
@@ -54,12 +54,14 @@ export function isRunParkedError(error: unknown): error is RunParkedError {
|
|
|
54
54
|
return error instanceof RunParkedError;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
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(
|
|
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
|
}
|
package/src/workflows/store.ts
CHANGED
|
@@ -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
|
-
|
|
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(),
|