@nylorun/runtime 0.10.0-beta → 0.11.0-beta

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 (190) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +53 -51
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/adapters/streams/s2.d.ts +18 -0
  7. package/dist/adapters/streams/s2.js +361 -0
  8. package/dist/core/executors.d.ts +4 -1
  9. package/dist/core/executors.js +20 -1
  10. package/dist/core/flow-host.d.ts +93 -132
  11. package/dist/core/flow-host.js +282 -547
  12. package/dist/core/sandbox-routes.d.ts +8 -3
  13. package/dist/core/sandbox-routes.js +10 -10
  14. package/dist/execution/memory.d.ts +54 -0
  15. package/dist/execution/memory.js +217 -0
  16. package/dist/execution/types.d.ts +125 -0
  17. package/dist/execution/types.js +52 -0
  18. package/dist/host/config-for.d.ts +2 -1
  19. package/dist/host/config-for.js +3 -21
  20. package/dist/host/config.d.ts +0 -8
  21. package/dist/host/create-host.d.ts +36 -1
  22. package/dist/host/create-host.js +48 -24
  23. package/dist/host/execution.d.ts +61 -0
  24. package/dist/host/execution.js +69 -0
  25. package/dist/host/http.d.ts +4 -0
  26. package/dist/host/http.js +6 -0
  27. package/dist/host/index.d.ts +3 -1
  28. package/dist/host/index.js +2 -0
  29. package/dist/host/main.js +106 -47
  30. package/dist/host/stack-config.d.ts +72 -0
  31. package/dist/host/stack-config.js +193 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/infra/database.d.ts +16 -0
  34. package/dist/infra/database.js +31 -0
  35. package/dist/infra/execution.d.ts +39 -0
  36. package/dist/infra/execution.js +68 -0
  37. package/dist/infra/index.d.ts +42 -0
  38. package/dist/infra/index.js +56 -0
  39. package/dist/infra/readiness.d.ts +33 -0
  40. package/dist/infra/readiness.js +53 -0
  41. package/dist/infra/streams.d.ts +17 -0
  42. package/dist/infra/streams.js +33 -0
  43. package/dist/infra/workers.d.ts +39 -0
  44. package/dist/infra/workers.js +31 -0
  45. package/dist/model/pi-model.d.ts +2 -2
  46. package/dist/model/pi-model.js +5 -5
  47. package/dist/sandbox/manager.d.ts +26 -8
  48. package/dist/sandbox/manager.js +95 -47
  49. package/dist/sandbox/select.d.ts +1 -1
  50. package/dist/sandbox/select.js +4 -5
  51. package/dist/sandbox/types.d.ts +2 -2
  52. package/dist/store/canonical.d.ts +5 -0
  53. package/dist/store/canonical.js +17 -0
  54. package/dist/store/cursor.d.ts +9 -0
  55. package/dist/store/cursor.js +18 -0
  56. package/dist/store/memory.d.ts +57 -0
  57. package/dist/store/memory.js +643 -0
  58. package/dist/store/ownership.d.ts +19 -0
  59. package/dist/store/ownership.js +33 -0
  60. package/dist/store/postgres/connect.d.ts +25 -0
  61. package/dist/store/postgres/connect.js +25 -0
  62. package/dist/store/postgres/locking.d.ts +16 -0
  63. package/dist/store/postgres/locking.js +22 -0
  64. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  65. package/dist/store/postgres/migrations/001_initial.js +196 -0
  66. package/dist/store/postgres/migrations/index.d.ts +49 -0
  67. package/dist/store/postgres/migrations/index.js +75 -0
  68. package/dist/store/postgres/names.d.ts +11 -0
  69. package/dist/store/postgres/names.js +42 -0
  70. package/dist/store/postgres/store.d.ts +11 -0
  71. package/dist/store/postgres/store.js +790 -0
  72. package/dist/store/postgres/tenants.d.ts +90 -0
  73. package/dist/store/postgres/tenants.js +207 -0
  74. package/dist/store/types.d.ts +440 -0
  75. package/dist/store/types.js +10 -0
  76. package/dist/streams/basin.d.ts +23 -0
  77. package/dist/streams/basin.js +51 -0
  78. package/dist/streams/memory.d.ts +25 -0
  79. package/dist/streams/memory.js +124 -0
  80. package/dist/streams/relay.d.ts +37 -0
  81. package/dist/streams/relay.js +254 -0
  82. package/dist/streams/types.d.ts +115 -0
  83. package/dist/streams/types.js +86 -0
  84. package/dist/tenant/actions.d.ts +153 -0
  85. package/dist/tenant/actions.js +191 -0
  86. package/dist/tenant/advance.d.ts +15 -0
  87. package/dist/tenant/advance.js +439 -0
  88. package/dist/tenant/auth.d.ts +11 -0
  89. package/dist/tenant/auth.js +34 -0
  90. package/dist/tenant/commands.d.ts +3 -0
  91. package/dist/tenant/commands.js +384 -0
  92. package/dist/tenant/context.d.ts +140 -0
  93. package/dist/tenant/context.js +40 -0
  94. package/dist/tenant/effects.d.ts +20 -0
  95. package/dist/tenant/effects.js +539 -0
  96. package/dist/tenant/envelope.d.ts +0 -3
  97. package/dist/tenant/envelope.js +2 -34
  98. package/dist/tenant/ephemeral.d.ts +15 -4
  99. package/dist/tenant/ephemeral.js +106 -38
  100. package/dist/tenant/http.d.ts +32 -0
  101. package/dist/tenant/http.js +51 -0
  102. package/dist/tenant/live.d.ts +92 -0
  103. package/dist/tenant/live.js +290 -0
  104. package/dist/tenant/model-setting.d.ts +16 -0
  105. package/dist/tenant/model-setting.js +12 -0
  106. package/dist/tenant/module.d.ts +29 -8
  107. package/dist/tenant/module.js +162 -158
  108. package/dist/tenant/paths.d.ts +12 -8
  109. package/dist/tenant/paths.js +38 -12
  110. package/dist/tenant/principals.d.ts +9 -13
  111. package/dist/tenant/principals.js +31 -38
  112. package/dist/tenant/quarantine-error.d.ts +1 -1
  113. package/dist/tenant/quarantine-error.js +2 -8
  114. package/dist/tenant/quarantine.d.ts +0 -8
  115. package/dist/tenant/quarantine.js +8 -30
  116. package/dist/tenant/reset.d.ts +7 -6
  117. package/dist/tenant/reset.js +7 -51
  118. package/dist/tenant/routes-tenant.d.ts +12 -0
  119. package/dist/tenant/routes-tenant.js +126 -0
  120. package/dist/tenant/routes.d.ts +9 -0
  121. package/dist/tenant/routes.js +142 -0
  122. package/dist/tenant/runtime.d.ts +59 -58
  123. package/dist/tenant/runtime.js +243 -2201
  124. package/dist/tenant/scheduler.d.ts +25 -0
  125. package/dist/tenant/scheduler.js +84 -0
  126. package/dist/tenant/session.d.ts +60 -0
  127. package/dist/tenant/session.js +94 -0
  128. package/dist/tenant/sessions.d.ts +34 -0
  129. package/dist/tenant/sessions.js +149 -0
  130. package/dist/tenant/status.d.ts +13 -4
  131. package/dist/tenant/status.js +76 -41
  132. package/dist/tenant/store-memory.d.ts +9 -12
  133. package/dist/tenant/store-memory.js +79 -68
  134. package/dist/tenant/store-pg.d.ts +15 -0
  135. package/dist/tenant/store-pg.js +140 -0
  136. package/dist/tenant/streams.d.ts +126 -0
  137. package/dist/tenant/streams.js +419 -0
  138. package/dist/tenant/sweep.d.ts +15 -0
  139. package/dist/tenant/sweep.js +122 -0
  140. package/dist/tenant/types.d.ts +95 -15
  141. package/dist/tenant/types.js +23 -1
  142. package/dist/tenant/worker.d.ts +84 -0
  143. package/dist/tenant/worker.js +189 -0
  144. package/dist/vault/service.d.ts +37 -31
  145. package/dist/vault/service.js +439 -359
  146. package/dist/version.d.ts +1 -1
  147. package/dist/version.js +1 -1
  148. package/package.json +13 -13
  149. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  150. package/dist/adapters/sandbox/microsandbox.js +0 -238
  151. package/dist/core/store.d.ts +0 -32
  152. package/dist/core/store.js +0 -143
  153. package/dist/launcher/commands.d.ts +0 -24
  154. package/dist/launcher/commands.js +0 -260
  155. package/dist/launcher/ensure-config.d.ts +0 -16
  156. package/dist/launcher/ensure-config.js +0 -79
  157. package/dist/launcher/errors.d.ts +0 -12
  158. package/dist/launcher/errors.js +0 -22
  159. package/dist/launcher/host-config.d.ts +0 -17
  160. package/dist/launcher/host-config.js +0 -64
  161. package/dist/launcher/host-state.d.ts +0 -17
  162. package/dist/launcher/host-state.js +0 -61
  163. package/dist/launcher/lifecycle.d.ts +0 -66
  164. package/dist/launcher/lifecycle.js +0 -407
  165. package/dist/launcher/locks.d.ts +0 -26
  166. package/dist/launcher/locks.js +0 -117
  167. package/dist/launcher/logs.d.ts +0 -18
  168. package/dist/launcher/logs.js +0 -117
  169. package/dist/launcher/main.d.ts +0 -2
  170. package/dist/launcher/main.js +0 -66
  171. package/dist/launcher/output.d.ts +0 -11
  172. package/dist/launcher/output.js +0 -68
  173. package/dist/launcher/paths.d.ts +0 -20
  174. package/dist/launcher/paths.js +0 -36
  175. package/dist/launcher/probe.d.ts +0 -23
  176. package/dist/launcher/probe.js +0 -72
  177. package/dist/launcher/protocol.d.ts +0 -62
  178. package/dist/launcher/protocol.js +0 -1
  179. package/dist/launcher/schema-guard.d.ts +0 -6
  180. package/dist/launcher/schema-guard.js +0 -36
  181. package/dist/launcher/spawn.d.ts +0 -22
  182. package/dist/launcher/spawn.js +0 -85
  183. package/dist/launcher/status.d.ts +0 -6
  184. package/dist/launcher/status.js +0 -65
  185. package/dist/tenant/migration.d.ts +0 -16
  186. package/dist/tenant/migration.js +0 -116
  187. package/dist/tenant/schema.d.ts +0 -23
  188. package/dist/tenant/schema.js +0 -151
  189. package/dist/tenant/store-fs.d.ts +0 -16
  190. package/dist/tenant/store-fs.js +0 -205
@@ -1,3 +1,50 @@
1
+ /**
2
+ * Flow host: the Session Store side of workflow sessions (architecture §10–12).
3
+ *
4
+ * Every function that touches state takes the caller's transaction `t: Tx` and
5
+ * is async. Nothing here publishes, notifies or schedules directly (seam rule
6
+ * 1): events go through `t.event(...)`, which the store publishes after
7
+ * commit, and wakes go through `t.afterCommit(() => schedule(id, wake))`. Functions
8
+ * that rewrite a session-scoped document lock that session first with
9
+ * `t.lockSession` (a no-op when the caller already holds it).
10
+ *
11
+ * Exported signatures:
12
+ *
13
+ * - `deriveSessionId(workflowSessionId, path, ...parts): string`
14
+ * - `deriveAgentEffectSessionId(workflowSessionId, path, request): string`
15
+ * - `isWorkflowManifest(manifest): manifest is WorkflowManifest`
16
+ * - `isFlowToolEffect(request): boolean`, `isFlowEffect(request): boolean`
17
+ * - `pathDepth(path): number`
18
+ * - `countActiveFlowWork(t, workflowSessionId, turnId): Promise<number>`
19
+ * - `commandKey(sessionId, idempotencyKey): string`
20
+ * - `linkedMessageKey(request): string`
21
+ * - `linkedTurnOf(t, agentSessionId, request): Promise<string | undefined>`
22
+ * - `linkedTurnEnd(t, effect, agent): Promise<LinkedTurnEnd | undefined>`
23
+ * - `wakeLinkedWorkflow({ t, agentSessionId, turnId, output?, failed?, cancelled?, error?, schedule }): Promise<void>`
24
+ * - `pendingAgentEffects(t): Promise<FlowEffect[]>`
25
+ * - `reconcilePendingAgentEffect({ t, effectId, schedule }): Promise<boolean>`
26
+ * - `claimedFnVerifyActions(t): Promise<Action[]>`
27
+ * - `reofferFnVerifyClaim(t, actionId): Promise<boolean>`
28
+ * - `planCancelCascade({ t, workflowSessionId, turnId }): Promise<CascadeCancelPlan>`
29
+ * - `cancelSiblingWork({ t, workflowSessionId, turnId, siblingPaths?, cancelEffectIds? }): Promise<CancelSiblingResult>`
30
+ * - `fenceWorkflowActions({ t, workflowSessionId, turnId }): Promise<{ cancelled; uncertain }>`
31
+ * - `aggregateWaits({ t, workflowSessionId }): Promise<FlowWait[]>`
32
+ * - `findInteractionOwner({ t, workflowSessionId, interactionId }): Promise<{ sessionId; path } | undefined>`
33
+ * - `foreignInteractionConflict({ t, workflowSessionId, interactionId }): Promise<{ status: 409; message; ownerSessionId } | undefined>`
34
+ * - `wakeForQueuedEffects({ t, workflowSessionId, turnId, limits, schedule }): Promise<boolean>`
35
+ *
36
+ * `schedule: (sessionId, wake) => void | Promise<void>` runs after commit, never inside `t`;
37
+ * `wake` carries the reason (`linked`, `flow`) and, where the cause has one, a dedupe key.
38
+ *
39
+ * An `agent` effect settles only from the linked turn it started. A linked agent session is
40
+ * reused by every iteration of a Loop (its id derives from the workflow and the path), so its
41
+ * status and `lastOutput` may belong to an earlier iteration's turn until this effect's message
42
+ * commits. The effect is bound to its turn through the message's idempotency key
43
+ * (`linkedMessageKey`): the `commands` document of that key names the turn it opened.
44
+ *
45
+ * The Tenant sweep (`tenant/sweep.ts`) calls the reconcile and re-offer functions, one
46
+ * transaction per effect or Action.
47
+ */
1
48
  import { createHash } from "node:crypto";
2
49
  import { mayDispatchMore } from "./limits.js";
3
50
  /** Deterministic agent session id: derive(workflowSessionId, path, …parts). */
@@ -41,54 +88,19 @@ export function isFlowEffect(request) {
41
88
  request.kind === "verify" ||
42
89
  isFlowToolEffect(request));
43
90
  }
44
- /**
45
- * Resolve flow effects: agent (via session contract), tool nodes, fn, verify.
46
- * Runtime never writes agent session state — only issues commands.
47
- */
48
- export function resolveFlowEffect(input) {
49
- const { request, store, session } = input;
50
- if (!isFlowEffect(request))
51
- throw new Error(`Not a flow effect: ${request.kind}`);
52
- const existing = store.get("effects", request.effectId);
53
- if (existing) {
54
- if (existing.status === "completed")
55
- return { status: "completed", outcome: existing.outcome };
56
- if (existing.status === "queued") {
57
- if (input.limits &&
58
- !mayDispatchMore(countActiveFlowWork(store, session.id, request.turnId), input.limits))
59
- return { status: "pending" };
60
- return dispatchQueuedEffect(input, existing);
61
- }
62
- if (request.kind === "agent")
63
- return settleAgentEffect(input, existing) ?? { status: "pending" };
64
- return {
65
- status: existing.status === "uncertain" || existing.status === "invoking"
66
- ? "uncertain"
67
- : "pending",
68
- };
69
- }
70
- if (input.limits &&
71
- !mayDispatchMore(countActiveFlowWork(store, session.id, request.turnId), input.limits)) {
72
- store.put("effects", request.effectId, {
73
- request,
74
- status: "queued",
75
- });
76
- return { status: "pending" };
77
- }
78
- if (request.kind === "agent")
79
- return startAgentEffect(input);
80
- if (isFlowToolEffect(request))
81
- return startToolNode(input);
82
- return startFnOrVerify(input);
91
+ const OPEN_ACTION = ["pending", "claimed"];
92
+ function scheduleAfterCommit(t, schedule, id, wake) {
93
+ t.afterCommit(() => schedule(id, wake));
83
94
  }
84
95
  /** Active agent turns + pending/claimed actions for one workflow turn. */
85
- export function countActiveFlowWork(store, workflowSessionId, turnId) {
96
+ export async function countActiveFlowWork(t, workflowSessionId, turnId) {
97
+ const effects = await t.effectsForTurn(workflowSessionId, turnId);
98
+ const openActions = new Set((await t.actionsForSession(workflowSessionId, {
99
+ turnId,
100
+ statuses: OPEN_ACTION,
101
+ })).map((action) => action.actionId));
86
102
  let n = 0;
87
- for (const effect of store.all("effects")) {
88
- if (effect.request?.sessionId !== workflowSessionId)
89
- continue;
90
- if (effect.request?.turnId !== turnId)
91
- continue;
103
+ for (const effect of effects) {
92
104
  if (effect.status === "queued")
93
105
  continue;
94
106
  if (effect.status === "completed" || effect.status === "cancelled")
@@ -97,228 +109,77 @@ export function countActiveFlowWork(store, workflowSessionId, turnId) {
97
109
  n += 1;
98
110
  continue;
99
111
  }
100
- if (effect.request?.kind === "fn" ||
112
+ if ((effect.request?.kind === "fn" ||
101
113
  effect.request?.kind === "verify" ||
102
- isFlowToolEffect(effect.request)) {
103
- const action = store.get("actions", effect.request.effectId);
104
- if (action &&
105
- (action.status === "pending" || action.status === "claimed"))
106
- n += 1;
107
- }
114
+ isFlowToolEffect(effect.request)) &&
115
+ openActions.has(effect.request.effectId))
116
+ n += 1;
108
117
  }
109
118
  return n;
110
119
  }
111
- function dispatchQueuedEffect(input, existing) {
112
- const { request, store } = input;
113
- store.put("effects", request.effectId, {
114
- ...existing,
115
- status: "pending",
116
- });
117
- if (request.kind === "agent")
118
- return startAgentEffect(input);
119
- if (isFlowToolEffect(request))
120
- return startToolNode(input);
121
- return startFnOrVerify(input);
120
+ /** The `commands` document key of a session command's idempotency key (`tenant/commands.ts`). */
121
+ export function commandKey(sessionId, idempotencyKey) {
122
+ return JSON.stringify([sessionId, idempotencyKey]);
122
123
  }
123
- function startFnOrVerify(input) {
124
- const { request, store, session } = input;
125
- store.put("effects", request.effectId, {
126
- request,
127
- status: "pending",
128
- });
129
- const kind = request.kind;
130
- const action = {
131
- actionId: request.effectId,
132
- sessionId: request.sessionId,
133
- turnId: request.turnId,
134
- agentId: request.agentId,
135
- manifestHash: request.manifestHash,
136
- implementationVersion: session.implementationVersion,
137
- input: request.input,
138
- context: request.context,
139
- status: "pending",
140
- generation: 0,
141
- claimId: null,
142
- leaseExpiresAt: null,
143
- kind,
144
- path: request.path,
145
- key: request.key,
146
- };
147
- store.put("actions", action.actionId, action);
148
- const event = store.event(session.id, session.activeTurnId, "action.pending", {
149
- actionId: action.actionId,
150
- kind: action.kind,
151
- path: action.path,
152
- key: action.key,
153
- input: action.input,
154
- });
155
- input.publish(event);
156
- input.notify();
157
- return { status: "pending" };
124
+ /** The idempotency key of the message a flow `agent` effect sends its linked session. */
125
+ export function linkedMessageKey(request) {
126
+ const path = request.input?.path ??
127
+ request.path;
128
+ return `${request.turnId}:${path}:${request.iterations ?? "-"}`;
158
129
  }
159
- function startToolNode(input) {
160
- const { request, store, session } = input;
161
- store.put("effects", request.effectId, {
162
- request,
163
- status: "pending",
164
- });
165
- const action = {
166
- actionId: request.effectId,
167
- sessionId: request.sessionId,
168
- turnId: request.turnId,
169
- agentId: request.agentId,
170
- manifestHash: request.manifestHash,
171
- implementationVersion: session.implementationVersion,
172
- input: request.input,
173
- context: request.context,
174
- status: "pending",
175
- generation: 0,
176
- claimId: null,
177
- leaseExpiresAt: null,
178
- kind: "tool",
179
- path: request.path,
180
- key: request.key,
181
- };
182
- store.put("actions", action.actionId, action);
183
- emitNodeStarted(input, {
184
- path: request.path,
185
- kind: "tool",
186
- key: request.key,
187
- iterations: request.iterations,
188
- });
189
- const event = store.event(session.id, session.activeTurnId, "action.pending", {
190
- actionId: action.actionId,
191
- kind: action.kind,
192
- path: action.path,
193
- key: action.key,
194
- input: action.input,
195
- });
196
- input.publish(event);
197
- input.notify();
198
- return { status: "pending" };
199
- }
200
- function startAgentEffect(input) {
201
- const { request, store, session } = input;
202
- const body = request.input;
203
- const path = body.path ?? request.path;
204
- const agentSessionId = deriveAgentEffectSessionId(session.id, path, request);
205
- const iterations = request.iterations ?? "-";
206
- const n = Number(request.context.n ?? iterations.split(".")[0] ?? 1);
207
- store.put("effects", request.effectId, {
208
- request,
209
- status: "pending",
210
- agentSessionId,
211
- });
212
- store.put("links", agentSessionId, {
213
- workflowSessionId: session.id,
214
- path,
215
- effectId: request.effectId,
216
- turnId: request.turnId,
217
- });
218
- const existingAgent = input.readSession(agentSessionId);
219
- if (!existingAgent) {
220
- input.putLinkedSession({
221
- id: agentSessionId,
222
- agentId: body.agentId,
223
- ownerUserId: session.ownerUserId,
224
- vaultIds: session.vaultIds,
225
- credentialSelections: session.credentialSelections,
226
- });
227
- }
228
- const idempotencyKey = `${request.turnId}:${path}:${iterations}`;
229
- const messageInput = body.input;
230
- const accepted = input.sendMessage({
231
- sessionId: agentSessionId,
232
- ...(typeof messageInput === "string"
233
- ? { content: messageInput }
234
- : { data: messageInput }),
235
- idempotencyKey,
236
- requestId: `flow-${request.effectId}`,
237
- });
238
- const iterationEvent = store.event(session.id, session.activeTurnId, "loop.iteration", {
239
- path: String(request.context.loopPath ?? path.split("/")[0]),
240
- n,
241
- sessionId: agentSessionId,
242
- turnId: accepted.turnId ?? undefined,
243
- });
244
- input.publish(iterationEvent);
245
- const nodeAgent = store.event(session.id, session.activeTurnId, "node.agent", {
246
- path,
247
- iterations,
248
- sessionId: agentSessionId,
249
- turnId: accepted.turnId,
250
- });
251
- input.publish(nodeAgent);
252
- return (settleAgentEffect(input, store.get("effects", request.effectId)) ?? {
253
- status: "pending",
254
- });
130
+ /**
131
+ * The linked turn an `agent` effect started: the turn its message opened, or undefined until
132
+ * that message commits.
133
+ */
134
+ export async function linkedTurnOf(t, agentSessionId, request) {
135
+ const accepted = await t.get("commands", commandKey(agentSessionId, linkedMessageKey(request)));
136
+ return accepted?.response?.turnId ?? undefined;
255
137
  }
256
- function settleAgentEffect(input, effect) {
257
- if (effect.status === "completed" && effect.outcome)
258
- return { status: "completed", outcome: effect.outcome };
259
- const agentSessionId = effect.agentSessionId ??
260
- deriveAgentEffectSessionId(input.session.id, input.request.input.path ?? input.request.path, input.request);
261
- const agent = input.readSession(agentSessionId);
262
- if (!agent)
138
+ /**
139
+ * How the linked turn an `agent` effect started ended, or undefined while it has not: before
140
+ * its message commits, the linked session's status and `lastOutput` describe an earlier turn.
141
+ * `agent` is the linked session, read in the caller's transaction.
142
+ */
143
+ export async function linkedTurnEnd(t, effect, agent) {
144
+ const id = effect.agentSessionId;
145
+ if (!agent || typeof id !== "string" || agent.activeTurnId !== null)
263
146
  return undefined;
264
- if (agent.status === "completed" && agent.activeTurnId === null) {
265
- const output = agent.lastOutput ?? null;
266
- const outcome = { value: output };
267
- input.store.put("effects", input.request.effectId, {
268
- request: input.request,
269
- status: "completed",
270
- outcome,
271
- agentSessionId,
272
- });
273
- return { status: "completed", outcome };
274
- }
275
- if (agent.status === "failed") {
276
- const outcome = {
277
- value: {
278
- kind: "failed",
279
- code: "agent.failed",
280
- message: agent.error ?? "Agent turn failed",
281
- },
282
- };
283
- input.store.put("effects", input.request.effectId, {
284
- request: input.request,
285
- status: "completed",
286
- outcome,
287
- agentSessionId,
288
- });
289
- return { status: "completed", outcome };
290
- }
291
- if (agent.status === "cancelled") {
292
- const outcome = {
293
- value: {
294
- kind: "failed",
295
- code: "agent.cancelled",
296
- message: agent.error ?? "Agent turn was cancelled",
297
- },
147
+ const turnId = await linkedTurnOf(t, id, effect.request);
148
+ // Sessions settled before `lastTurnId` was recorded: the turn ended if none is active.
149
+ if (!turnId || (agent.lastTurnId ?? turnId) !== turnId)
150
+ return undefined;
151
+ if (agent.status === "completed")
152
+ return { turnId, status: "completed", output: agent.lastOutput ?? null };
153
+ if (agent.status === "failed" || agent.status === "cancelled")
154
+ return {
155
+ turnId,
156
+ status: agent.status,
157
+ ...(agent.error !== undefined ? { error: agent.error } : {}),
298
158
  };
299
- input.store.put("effects", input.request.effectId, {
300
- request: input.request,
301
- status: "completed",
302
- outcome,
303
- agentSessionId,
304
- });
305
- return { status: "completed", outcome };
306
- }
307
159
  return undefined;
308
160
  }
309
- /** After an agent turn settles, wake the owning workflow if linked. */
310
- export function wakeLinkedWorkflow(input) {
311
- const link = input.store.get("links", input.agentSessionId);
161
+ /**
162
+ * After a linked agent turn ends, settle the `agent` effect that started it and wake the
163
+ * workflow. `turnId` is the linked session's turn that ended; an effect bound to another turn
164
+ * (a later iteration whose message has not committed yet) is left alone.
165
+ */
166
+ export async function wakeLinkedWorkflow(input) {
167
+ const { t } = input;
168
+ const link = await t.get("links", input.agentSessionId);
312
169
  if (!link)
313
170
  return;
314
- const effect = input.store.get("effects", link.effectId);
315
- if (!effect || effect.status === "completed")
316
- return;
317
- const workflow = input.store.get("sessions", link.workflowSessionId);
171
+ const workflow = await t.lockSession(link.workflowSessionId);
318
172
  if (!workflow)
319
173
  return;
174
+ const effect = await t.get("effects", link.effectId);
175
+ if (!effect || effect.status === "completed")
176
+ return;
320
177
  if (workflow.activeTurnId !== link.turnId)
321
178
  return;
179
+ if (!input.turnId ||
180
+ (await linkedTurnOf(t, input.agentSessionId, effect.request)) !==
181
+ input.turnId)
182
+ return;
322
183
  const outcome = input.cancelled
323
184
  ? {
324
185
  value: {
@@ -336,191 +197,87 @@ export function wakeLinkedWorkflow(input) {
336
197
  },
337
198
  }
338
199
  : { value: input.output ?? null };
339
- input.store.put("effects", link.effectId, {
200
+ await t.put("effects", link.effectId, {
340
201
  ...effect,
341
202
  status: "completed",
342
203
  outcome,
343
204
  });
344
205
  workflow.status = "runnable";
345
- input.store.put("sessions", workflow.id, workflow);
346
- input.schedule(workflow.id);
206
+ await t.put("sessions", workflow.id, workflow);
207
+ scheduleAfterCommit(t, input.schedule, workflow.id, {
208
+ reason: "linked",
209
+ dedupeKey: `linked:${link.turnId}:${link.effectId}`,
210
+ });
347
211
  }
348
- /** On Runtime start: settle pending agent effects whose linked turns already finished. */
349
- export function reconcilePendingAgentEffects(input) {
350
- for (const effect of input.store.all("effects")) {
351
- if (effect.request?.kind !== "agent" || effect.status !== "pending")
352
- continue;
353
- const id = typeof effect.agentSessionId === "string"
354
- ? effect.agentSessionId
355
- : undefined;
356
- if (!id)
357
- continue;
358
- const agent = input.store.get("sessions", id);
359
- const workflow = input.store.get("sessions", effect.request.sessionId);
360
- if (workflow && workflow.status === "waiting") {
361
- workflow.status = "runnable";
362
- input.store.put("sessions", workflow.id, workflow);
363
- input.schedule(workflow.id);
364
- }
365
- if (!agent)
366
- continue;
367
- if (agent.status === "completed") {
368
- wakeLinkedWorkflow({
369
- agentSessionId: id,
370
- store: input.store,
371
- output: agent.lastOutput ?? null,
372
- schedule: input.schedule,
373
- publish: input.publish,
374
- });
375
- }
376
- else if (agent.status === "failed") {
377
- wakeLinkedWorkflow({
378
- agentSessionId: id,
379
- store: input.store,
380
- failed: true,
381
- error: agent.error,
382
- schedule: input.schedule,
383
- publish: input.publish,
384
- });
385
- }
386
- else if (agent.status === "cancelled") {
387
- wakeLinkedWorkflow({
388
- agentSessionId: id,
389
- store: input.store,
390
- cancelled: true,
391
- error: agent.error,
392
- schedule: input.schedule,
393
- publish: input.publish,
394
- });
395
- }
396
- else if (agent.status === "running" || agent.status === "runnable") {
397
- input.schedule(id);
398
- }
399
- else if (agent.status === "paused") {
400
- // Linked pause surfaces on the workflow waits list; keep waiting.
401
- if (workflow && workflow.status !== "paused") {
402
- const waits = aggregateWaits({
403
- store: input.store,
404
- workflowSessionId: workflow.id,
405
- });
406
- if (waits.length > 0) {
407
- workflow.status = "paused";
408
- workflow.waits = waits;
409
- input.store.put("sessions", workflow.id, workflow);
410
- }
411
- }
412
- }
413
- }
414
- }
415
- /** Re-offer orphaned fn/verify claims after a process restart (SD-P7 / WF-C9). */
416
- export function reofferOrphanedFnVerifyClaims(store) {
417
- let n = 0;
418
- for (const action of store.all("actions")) {
419
- if (action.status === "claimed" &&
420
- (action.kind === "fn" || action.kind === "verify")) {
421
- action.status = "pending";
422
- action.claimId = null;
423
- action.leaseExpiresAt = null;
424
- store.put("actions", action.actionId, action);
425
- const s = store.get("sessions", action.sessionId);
426
- if (s && (s.status === "waiting" || s.status === "running")) {
427
- s.status = "runnable";
428
- store.put("sessions", s.id, s);
429
- }
430
- n += 1;
431
- }
432
- }
433
- return n;
212
+ /** Pending workflow `agent` effects with a linked session, for the Tenant sweep. */
213
+ export async function pendingAgentEffects(t) {
214
+ return (await t.effectsWithStatus(["pending"], { kinds: ["agent"] })).filter((effect) => typeof effect.agentSessionId === "string");
434
215
  }
435
216
  /**
436
- * On lease expiry: fn/verify → pending again; tool nodes → uncertain (WF-R35, WF-R38).
437
- * Returns whether any fn/verify was redelivered.
217
+ * Tenant sweep: settle one pending `agent` effect whose linked turn already finished (the
218
+ * linked settle normally does this in its own transaction). Runs in its own transaction and
219
+ * locks the linked agent (child) session before the workflow (parent), per the lock order in
220
+ * `store/types.ts`. Only the linked turn the effect started settles it (`linkedTurnEnd`): the
221
+ * linked session may still show an earlier iteration's settled turn while this effect's message
222
+ * is not committed. A linked session still `running` or `runnable` is left to the sweep's
223
+ * orphan re-wake; a paused one surfaces on the workflow when the workflow settles. Returns
224
+ * true when it changed something.
438
225
  */
439
- export function applyFlowLeaseExpiry(input) {
440
- if (input.action.status !== "claimed" || !input.action.leaseExpiresAt)
441
- return "skip";
442
- const now = input.now ?? Date.now();
443
- if (Date.parse(input.action.leaseExpiresAt) > now)
444
- return "skip";
445
- if (input.action.kind === "fn" || input.action.kind === "verify") {
446
- input.action.status = "pending";
447
- input.action.claimId = null;
448
- input.action.leaseExpiresAt = null;
449
- input.store.put("actions", input.action.actionId, input.action);
450
- return "reoffer";
451
- }
452
- if (input.action.kind === "tool" &&
453
- "path" in input.action &&
454
- !("capabilityId" in input.action)) {
455
- input.action.status = "uncertain";
456
- input.store.put("actions", input.action.actionId, input.action);
457
- const effect = input.store.get("effects", input.action.actionId);
458
- if (effect) {
459
- effect.status = "uncertain";
460
- input.store.put("effects", input.action.actionId, effect);
461
- }
462
- return "uncertain";
463
- }
464
- return "skip";
465
- }
466
- /** Emit loop.verified / loop.decided after fn/verify actions complete. */
467
- export function emitLoopActionEvents(input) {
468
- const { action, store, session } = input;
469
- if (action.kind !== "fn" && action.kind !== "verify")
470
- return;
471
- const n = Number(action.context?.n ?? 1);
472
- const path = String(action.context?.loopPath ?? action.path ?? "");
473
- if (action.kind === "verify") {
474
- const verdict = input.value;
475
- if (verdict?.kind === "failed")
476
- return;
477
- const event = store.event(session.id, session.activeTurnId, "loop.verified", {
478
- path,
479
- n,
480
- pass: Boolean(verdict?.pass),
481
- ...(verdict?.feedback !== undefined
482
- ? { feedback: verdict.feedback }
483
- : {}),
484
- ...(verdict?.data !== undefined ? { data: verdict.data } : {}),
485
- });
486
- input.publish(event);
487
- return;
488
- }
489
- if (action.context?.role !== "decide")
490
- return;
491
- const decision = input.value;
492
- const event = store.event(session.id, session.activeTurnId, "loop.decided", {
493
- path,
494
- n,
495
- next: "output" in decision && !("input" in decision) ? "output" : "input",
496
- patched: Boolean(decision.agent),
226
+ export async function reconcilePendingAgentEffect(input) {
227
+ const { t } = input;
228
+ const found = await t.get("effects", input.effectId);
229
+ const id = found?.agentSessionId;
230
+ if (!found || found.request?.kind !== "agent" || typeof id !== "string")
231
+ return false;
232
+ const agent = await t.lockSession(id);
233
+ const workflow = await t.lockSession(found.request.sessionId);
234
+ // Read again under both locks.
235
+ const effect = await t.get("effects", input.effectId);
236
+ if (!effect || effect.status !== "pending" || !agent || !workflow)
237
+ return false;
238
+ const end = await linkedTurnEnd(t, effect, agent);
239
+ if (!end)
240
+ return false;
241
+ await wakeLinkedWorkflow({
242
+ t,
243
+ agentSessionId: id,
244
+ turnId: end.turnId,
245
+ ...(end.status === "completed"
246
+ ? { output: end.output }
247
+ : end.status === "failed"
248
+ ? { failed: true, error: end.error }
249
+ : { cancelled: true, error: end.error }),
250
+ schedule: input.schedule,
497
251
  });
498
- input.publish(event);
499
- }
500
- export function emitNodeStarted(input, payload) {
501
- const event = input.store.event(input.session.id, input.session.activeTurnId, "node.started", payload);
502
- input.publish(event);
503
- return event;
504
- }
505
- export function emitNodeCompleted(input, payload) {
506
- const event = input.store.event(input.session.id, input.session.activeTurnId, "node.completed", payload);
507
- input.publish(event);
508
- return event;
509
- }
510
- export function emitNodeFailed(input, payload) {
511
- const event = input.store.event(input.session.id, input.session.activeTurnId, "node.failed", payload);
512
- input.publish(event);
513
- return event;
252
+ return true;
514
253
  }
515
- export function emitSwitchSelected(input, payload) {
516
- const event = input.store.event(input.session.id, input.session.activeTurnId, "switch.selected", payload);
517
- input.publish(event);
518
- return event;
254
+ /** Claimed `fn`/`verify` Actions, for the re-offer after a Tenant opens. */
255
+ export async function claimedFnVerifyActions(t) {
256
+ return t.actionsWithStatus(["claimed"], { kinds: ["fn", "verify"] });
519
257
  }
520
- export function emitMapItems(input, payload) {
521
- const event = input.store.event(input.session.id, input.session.activeTurnId, "map.items", payload);
522
- input.publish(event);
523
- return event;
258
+ /**
259
+ * Re-offer one claimed `fn`/`verify` Action (SD-P7 / WF-C9): they are pure or repeat-safe, so
260
+ * a claim left by an executor of an earlier process is offered again at once rather than
261
+ * after its lease. The next claim bumps the generation, which fences a late result. Locks
262
+ * only the Action's session. Returns true when it re-offered.
263
+ */
264
+ export async function reofferFnVerifyClaim(t, actionId) {
265
+ const found = await t.get("actions", actionId);
266
+ if (!found)
267
+ return false;
268
+ await t.lockSession(found.sessionId);
269
+ // Read again under the session lock.
270
+ const action = await t.get("actions", actionId);
271
+ if (!action ||
272
+ action.status !== "claimed" ||
273
+ (action.kind !== "fn" && action.kind !== "verify"))
274
+ return false;
275
+ action.status = "pending";
276
+ action.claimId = null;
277
+ action.leaseExpiresAt = null;
278
+ await t.put("actions", action.actionId, action);
279
+ t.signalWork();
280
+ return true;
524
281
  }
525
282
  /** Path depth for deepest-first cancel ordering. */
526
283
  export function pathDepth(path) {
@@ -532,23 +289,17 @@ export function pathDepth(path) {
532
289
  * Plan a cancel cascade for a workflow session: linked agents deepest first,
533
290
  * then pending → cancelled and claimed → uncertain actions (SD-P11 / WF-R53).
534
291
  */
535
- export function planCancelCascade(input) {
536
- // links table is keyed by agentSessionId — discover via sessions + get(links, id).
537
- const agentEntries = [];
538
- for (const sess of input.store.all("sessions")) {
539
- const link = input.store.get("links", sess.id);
540
- if (!link || link.workflowSessionId !== input.workflowSessionId)
541
- continue;
542
- agentEntries.push({ sessionId: sess.id, path: link.path });
543
- }
292
+ export async function planCancelCascade(input) {
293
+ const { t } = input;
294
+ const agentEntries = (await t.linkedSessions(input.workflowSessionId)).map((linked) => ({ sessionId: linked.agentSessionId, path: linked.link.path }));
544
295
  agentEntries.sort((a, b) => pathDepth(b.path) - pathDepth(a.path));
545
296
  const pendingActionIds = [];
546
297
  const claimedActionIds = [];
547
- for (const action of input.store.all("actions")) {
548
- if (action.sessionId !== input.workflowSessionId)
549
- continue;
550
- if (input.turnId !== null && action.turnId !== input.turnId)
551
- continue;
298
+ const actions = await t.actionsForSession(input.workflowSessionId, {
299
+ ...(input.turnId !== null ? { turnId: input.turnId } : {}),
300
+ statuses: OPEN_ACTION,
301
+ });
302
+ for (const action of actions) {
552
303
  if (action.status === "pending")
553
304
  pendingActionIds.push(action.actionId);
554
305
  else if (action.status === "claimed")
@@ -567,13 +318,15 @@ export function planCancelCascade(input) {
567
318
  * When `cancelEffectIds` is provided, those effects are marked cancelled and their
568
319
  * paths are included in the sibling path set.
569
320
  */
570
- export function cancelSiblingWork(input) {
321
+ export async function cancelSiblingWork(input) {
322
+ const { t } = input;
323
+ await t.lockSession(input.workflowSessionId);
571
324
  const cancelledActions = [];
572
325
  const uncertainActions = [];
573
326
  const agentSessionIds = [];
574
327
  const siblingPaths = new Set(input.siblingPaths ?? []);
575
328
  for (const effectId of input.cancelEffectIds ?? []) {
576
- const effect = input.store.get("effects", effectId);
329
+ const effect = await t.get("effects", effectId);
577
330
  if (!effect)
578
331
  continue;
579
332
  const path = typeof effect.request?.path === "string"
@@ -585,7 +338,7 @@ export function cancelSiblingWork(input) {
585
338
  effect.status === "queued" ||
586
339
  effect.status === "uncertain") {
587
340
  effect.status = "cancelled";
588
- input.store.put("effects", effectId, effect);
341
+ await t.put("effects", effectId, effect);
589
342
  }
590
343
  }
591
344
  const matchesSibling = (path) => {
@@ -593,66 +346,56 @@ export function cancelSiblingWork(input) {
593
346
  return false;
594
347
  return [...siblingPaths].some((sib) => path === sib || path.startsWith(`${sib}/`));
595
348
  };
596
- for (const sess of input.store.all("sessions")) {
597
- const link = input.store.get("links", sess.id);
598
- if (!link || link.workflowSessionId !== input.workflowSessionId)
599
- continue;
349
+ for (const { agentSessionId, link, session } of await t.linkedSessions(input.workflowSessionId)) {
600
350
  if (!matchesSibling(link.path))
601
351
  continue;
602
- if (sess.activeTurnId &&
603
- ["running", "runnable", "paused", "waiting"].includes(sess.status))
604
- agentSessionIds.push(sess.id);
352
+ if (session.activeTurnId &&
353
+ ["running", "runnable", "paused", "waiting"].includes(session.status))
354
+ agentSessionIds.push(agentSessionId);
605
355
  }
606
- for (const action of input.store.all("actions")) {
607
- if (action.sessionId !== input.workflowSessionId)
608
- continue;
609
- if (action.turnId !== input.turnId)
610
- continue;
356
+ const actions = await t.actionsForSession(input.workflowSessionId, {
357
+ turnId: input.turnId,
358
+ statuses: OPEN_ACTION,
359
+ });
360
+ for (const action of actions) {
611
361
  if (!matchesSibling(action.path))
612
362
  continue;
613
- if (action.status === "pending") {
614
- action.status = "cancelled";
615
- input.store.put("actions", action.actionId, action);
616
- const effect = input.store.get("effects", action.actionId);
617
- if (effect) {
618
- effect.status = "cancelled";
619
- input.store.put("effects", action.actionId, effect);
620
- }
621
- cancelledActions.push(action.actionId);
363
+ const next = action.status === "claimed" ? "uncertain" : "cancelled";
364
+ action.status = next;
365
+ await t.put("actions", action.actionId, action);
366
+ const effect = await t.get("effects", action.actionId);
367
+ if (effect) {
368
+ effect.status = next;
369
+ await t.put("effects", action.actionId, effect);
622
370
  }
623
- else if (action.status === "claimed") {
624
- action.status = "uncertain";
625
- input.store.put("actions", action.actionId, action);
626
- const effect = input.store.get("effects", action.actionId);
627
- if (effect) {
628
- effect.status = "uncertain";
629
- input.store.put("effects", action.actionId, effect);
630
- }
371
+ if (next === "cancelled")
372
+ cancelledActions.push(action.actionId);
373
+ else
631
374
  uncertainActions.push(action.actionId);
632
- }
633
375
  }
634
376
  return { cancelledActions, uncertainActions, agentSessionIds };
635
377
  }
636
378
  /**
637
379
  * Apply cancel fencing to workflow-session actions (pending→cancelled, claimed→uncertain).
638
380
  */
639
- export function fenceWorkflowActions(input) {
381
+ export async function fenceWorkflowActions(input) {
382
+ const { t } = input;
383
+ await t.lockSession(input.workflowSessionId);
384
+ const turn = input.turnId !== null ? { turnId: input.turnId } : {};
640
385
  const cancelled = [];
641
386
  const uncertain = [];
642
- for (const action of input.store.all("actions")) {
643
- if (action.sessionId !== input.workflowSessionId)
644
- continue;
645
- if (input.turnId !== null && action.turnId !== input.turnId)
646
- continue;
647
- if (!["pending", "claimed"].includes(action.status))
648
- continue;
387
+ const actions = await t.actionsForSession(input.workflowSessionId, {
388
+ ...turn,
389
+ statuses: OPEN_ACTION,
390
+ });
391
+ for (const action of actions) {
649
392
  const next = action.status === "claimed" ? "uncertain" : "cancelled";
650
393
  action.status = next;
651
- input.store.put("actions", action.actionId, action);
652
- const effect = input.store.get("effects", action.actionId);
394
+ await t.put("actions", action.actionId, action);
395
+ const effect = await t.get("effects", action.actionId);
653
396
  if (effect) {
654
397
  effect.status = next;
655
- input.store.put("effects", action.actionId, effect);
398
+ await t.put("effects", action.actionId, effect);
656
399
  }
657
400
  if (next === "cancelled")
658
401
  cancelled.push(action.actionId);
@@ -660,85 +403,77 @@ export function fenceWorkflowActions(input) {
660
403
  uncertain.push(action.actionId);
661
404
  }
662
405
  // Drop queued effects that never started.
663
- for (const effect of input.store.all("effects")) {
664
- if (effect.request?.sessionId !== input.workflowSessionId)
665
- continue;
666
- if (input.turnId !== null && effect.request?.turnId !== input.turnId)
667
- continue;
668
- if (effect.status === "queued") {
669
- effect.status = "cancelled";
670
- input.store.put("effects", effect.request.effectId, effect);
671
- cancelled.push(effect.request.effectId);
672
- }
406
+ const queued = await t.effectsForSession(input.workflowSessionId, { ...turn, statuses: ["queued"] });
407
+ for (const effect of queued) {
408
+ effect.status = "cancelled";
409
+ await t.put("effects", effect.request.effectId, effect);
410
+ cancelled.push(effect.request.effectId);
673
411
  }
674
412
  return { cancelled, uncertain };
675
413
  }
414
+ function waitsFromSession(sessionId, path, raw) {
415
+ if (!Array.isArray(raw))
416
+ return [];
417
+ const waits = [];
418
+ for (const call of raw) {
419
+ const interaction = call.interaction ?? call;
420
+ const interactionId = String(interaction?.id ?? call.interactionId ?? "");
421
+ if (!interactionId)
422
+ continue;
423
+ waits.push({
424
+ sessionId,
425
+ path,
426
+ interactionId,
427
+ kind: String(interaction?.kind ?? call.kind ?? "approval"),
428
+ ...(call.invocationId !== undefined
429
+ ? { invocationId: String(call.invocationId) }
430
+ : {}),
431
+ ...(call.wait !== undefined ? { wait: call.wait } : {}),
432
+ ...(call.status !== undefined ? { status: String(call.status) } : {}),
433
+ });
434
+ }
435
+ return waits;
436
+ }
676
437
  /**
677
438
  * Aggregate human waits across the workflow session and linked agent sessions (WF-R51).
678
439
  */
679
- export function aggregateWaits(input) {
680
- const waits = [];
681
- const workflow = input.store.get("sessions", input.workflowSessionId);
440
+ export async function aggregateWaits(input) {
441
+ const { t } = input;
442
+ const workflow = await t.get("sessions", input.workflowSessionId);
682
443
  if (!workflow)
683
- return waits;
684
- const pushFromSession = (session, path, raw) => {
685
- if (!Array.isArray(raw))
686
- return;
687
- for (const call of raw) {
688
- const interaction = call.interaction ?? call;
689
- const interactionId = String(interaction?.id ?? call.interactionId ?? "");
690
- if (!interactionId)
691
- continue;
692
- waits.push({
693
- sessionId: session.id,
694
- path,
695
- interactionId,
696
- kind: String(interaction?.kind ?? call.kind ?? "approval"),
697
- ...(call.invocationId !== undefined
698
- ? { invocationId: String(call.invocationId) }
699
- : {}),
700
- ...(call.wait !== undefined ? { wait: call.wait } : {}),
701
- ...(call.status !== undefined ? { status: String(call.status) } : {}),
702
- });
703
- }
704
- };
444
+ return [];
445
+ const waits = [];
705
446
  // Workflow-owned interactions (tool-node / verify approvals) live on the workflow session.
706
447
  if (Array.isArray(workflow.waits))
707
- pushFromSession(workflow, "", workflow.waits);
448
+ waits.push(...waitsFromSession(workflow.id, "", workflow.waits));
708
449
  else if (workflow.waits &&
709
450
  typeof workflow.waits === "object" &&
710
451
  Array.isArray(workflow.waits.interactions))
711
- pushFromSession(workflow, "", workflow.waits.interactions);
712
- for (const sess of input.store.all("sessions")) {
713
- const link = input.store.get("links", sess.id);
714
- if (!link || link.workflowSessionId !== input.workflowSessionId)
715
- continue;
716
- if (sess.status !== "paused")
452
+ waits.push(...waitsFromSession(workflow.id, "", workflow.waits.interactions));
453
+ for (const { agentSessionId, link, session } of await t.linkedSessions(input.workflowSessionId)) {
454
+ if (session.status !== "paused")
717
455
  continue;
718
- pushFromSession(sess, link.path, sess.waits);
456
+ waits.push(...waitsFromSession(agentSessionId, link.path, session.waits));
719
457
  }
720
458
  return waits;
721
459
  }
722
460
  /**
723
461
  * Look up which session owns an interaction id. Used for 409 foreign approvals (WF-R52).
724
462
  */
725
- export function findInteractionOwner(input) {
726
- const waits = aggregateWaits(input);
463
+ export async function findInteractionOwner(input) {
464
+ const waits = await aggregateWaits(input);
727
465
  const hit = waits.find((w) => w.interactionId === input.interactionId);
728
466
  if (hit)
729
467
  return { sessionId: hit.sessionId, path: hit.path };
730
- // Also scan linked paused sessions' plan-shaped waits that aggregateWaits may have missed.
731
- for (const sess of input.store.all("sessions")) {
732
- const link = input.store.get("links", sess.id);
733
- if (!link || link.workflowSessionId !== input.workflowSessionId)
734
- continue;
735
- const raw = sess.waits;
468
+ // Also scan linked sessions' waits that aggregateWaits skipped (not paused).
469
+ for (const { agentSessionId, link, session } of await input.t.linkedSessions(input.workflowSessionId)) {
470
+ const raw = session.waits;
736
471
  if (!Array.isArray(raw))
737
472
  continue;
738
473
  for (const call of raw) {
739
474
  const id = String(call.interaction?.id ?? call.interactionId ?? "");
740
475
  if (id === input.interactionId)
741
- return { sessionId: sess.id, path: link.path };
476
+ return { sessionId: agentSessionId, path: link.path };
742
477
  }
743
478
  }
744
479
  return undefined;
@@ -747,8 +482,8 @@ export function findInteractionOwner(input) {
747
482
  * If approve/respond targets an interaction owned by a linked agent session,
748
483
  * return a 409 message naming that session (WF-R52 / LOOP-A4).
749
484
  */
750
- export function foreignInteractionConflict(input) {
751
- const owner = findInteractionOwner(input);
485
+ export async function foreignInteractionConflict(input) {
486
+ const owner = await findInteractionOwner(input);
752
487
  if (!owner)
753
488
  return undefined;
754
489
  if (owner.sessionId === input.workflowSessionId)
@@ -761,23 +496,23 @@ export function foreignInteractionConflict(input) {
761
496
  }
762
497
  /**
763
498
  * When concurrency slots free, re-enter the workflow so queued effects can start
764
- * (status stays `queued` until `resolveFlowEffect` dispatches them).
499
+ * (status stays `queued` until the effect resolver dispatches them). Returns
500
+ * true when a wake was scheduled.
765
501
  */
766
- export function wakeForQueuedEffects(input) {
767
- if (!mayDispatchMore(countActiveFlowWork(input.store, input.workflowSessionId, input.turnId), input.limits))
502
+ export async function wakeForQueuedEffects(input) {
503
+ const { t } = input;
504
+ if (!mayDispatchMore(await countActiveFlowWork(t, input.workflowSessionId, input.turnId), input.limits))
768
505
  return false;
769
- const hasQueued = input.store
770
- .all("effects")
771
- .some((e) => e.status === "queued" &&
772
- e.request?.sessionId === input.workflowSessionId &&
773
- e.request?.turnId === input.turnId);
774
- if (!hasQueued)
506
+ const queued = await t.effectsForTurn(input.workflowSessionId, input.turnId, ["queued"]);
507
+ if (queued.length === 0)
775
508
  return false;
776
- const workflow = input.store.get("sessions", input.workflowSessionId);
509
+ const workflow = await t.lockSession(input.workflowSessionId);
777
510
  if (workflow && workflow.status === "waiting") {
778
511
  workflow.status = "runnable";
779
- input.store.put("sessions", workflow.id, workflow);
512
+ await t.put("sessions", workflow.id, workflow);
780
513
  }
781
- input.schedule(input.workflowSessionId);
514
+ scheduleAfterCommit(t, input.schedule, input.workflowSessionId, {
515
+ reason: "flow",
516
+ });
782
517
  return true;
783
518
  }