conductor-remote 1.106.3 → 1.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2119 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { hashCapabilityToken, WorkflowTransitionError } from "./orchestration-db.js";
3
+ import { scrubWorkflowSecrets, WORKFLOW_PRIVATE_ENVELOPE_CLOSE, WORKFLOW_PRIVATE_ENVELOPE_OPEN, workflowCapabilityToken, workflowPrivateEnvelope } from "./shared.js";
4
+ import { prepareWorkflowRun, workflowBootstrapPrompt, workflowChildPrompt, workflowRootPrompt } from "./workflow.js";
5
+ import { assertWorkflowDelegation, isTerminalWorkflowJobState, isTerminalWorkflowPhase, phaseAfterDeliveredBaton, workflowDelegationTransition } from "./workflow-machine.js";
6
+ import { withUiDispatchHook, withUiPriority } from "./writes.js";
7
+ const MAX_WAKE_STEPS = 64;
8
+ export class WorkflowCoordinatorError extends Error {
9
+ code;
10
+ status;
11
+ retryable;
12
+ constructor(code, message, options = {}) {
13
+ super(scrubWorkflowSecrets(message));
14
+ this.name = 'WorkflowCoordinatorError';
15
+ this.code = code;
16
+ this.status = options.status ?? 409;
17
+ this.retryable = options.retryable ?? false;
18
+ }
19
+ }
20
+ function cleanUnknown(value, depth = 0) {
21
+ if (depth > 12)
22
+ return '[truncated]';
23
+ if (typeof value === 'string')
24
+ return scrubWorkflowSecrets(value);
25
+ if (Array.isArray(value))
26
+ return value.map(entry => cleanUnknown(entry, depth + 1));
27
+ if (value && typeof value === 'object') {
28
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cleanUnknown(entry, depth + 1)]));
29
+ }
30
+ return value;
31
+ }
32
+ function errorMessage(error) {
33
+ return scrubWorkflowSecrets(error instanceof Error ? error.message : String(error)).slice(0, 500);
34
+ }
35
+ function errorCode(error, fallback) {
36
+ if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
37
+ return scrubWorkflowSecrets(error.code).slice(0, 100);
38
+ }
39
+ return fallback;
40
+ }
41
+ function preExecutionRetryClass(error) {
42
+ return errorCode(error, 'workflow_effect_failed') === 'workflow_root_not_pristine' ? 'terminal' : 'deterministic';
43
+ }
44
+ function sanitizeChildOutcome(outcome) {
45
+ return outcome.kind === 'success'
46
+ ? {
47
+ kind: 'success',
48
+ baton: scrubWorkflowSecrets(outcome.baton),
49
+ ...(outcome.evidence === undefined ? {} : { evidence: cleanUnknown(outcome.evidence) })
50
+ }
51
+ : {
52
+ kind: 'failure',
53
+ code: scrubWorkflowSecrets(outcome.code),
54
+ message: scrubWorkflowSecrets(outcome.message),
55
+ retryClass: outcome.retryClass ?? 'deterministic',
56
+ ...(outcome.evidence === undefined ? {} : { evidence: cleanUnknown(outcome.evidence) })
57
+ };
58
+ }
59
+ function isDeliveryReceipt(value) {
60
+ if (!value || typeof value !== 'object')
61
+ return false;
62
+ const receipt = value;
63
+ if (receipt.kind === 'outbox')
64
+ return typeof receipt.id === 'string' && receipt.id.length > 0;
65
+ return (receipt.kind === 'message' &&
66
+ typeof receipt.id === 'string' &&
67
+ typeof receipt.rowid === 'number' &&
68
+ Number.isSafeInteger(receipt.rowid) &&
69
+ (receipt.turnId === null || typeof receipt.turnId === 'string'));
70
+ }
71
+ function messageReceipt(resolution, expectedId) {
72
+ if (resolution.status !== 'delivered' || resolution.receipt.id !== expectedId)
73
+ return null;
74
+ return resolution.receipt;
75
+ }
76
+ function effectSessionId(effect) {
77
+ if (!effect.target || typeof effect.target !== 'object')
78
+ return undefined;
79
+ const sessionId = effect.target.sessionId;
80
+ return typeof sessionId === 'string' && sessionId ? sessionId : undefined;
81
+ }
82
+ function receiptEventSuffix(receipt, fallback) {
83
+ return isDeliveryReceipt(receipt) ? `${receipt.kind}:${receipt.id}` : fallback;
84
+ }
85
+ /** Stable, non-secret evidence used to match an orphaned external effect. */
86
+ export function workflowEffectCorrelationMarker(runId, actionId) {
87
+ return `[conductor-remote workflow:${runId} action:${actionId}]`;
88
+ }
89
+ /** Raw Conductor sees the marker; every scrubbed transcript surface removes it. */
90
+ function privateCorrelationBlock(runId, actionId) {
91
+ return [
92
+ WORKFLOW_PRIVATE_ENVELOPE_OPEN,
93
+ workflowEffectCorrelationMarker(runId, actionId),
94
+ WORKFLOW_PRIVATE_ENVELOPE_CLOSE
95
+ ].join('\n');
96
+ }
97
+ function sameRelay(left, right) {
98
+ return Boolean(left &&
99
+ left.instanceId === right.instanceId &&
100
+ left.pid === right.pid &&
101
+ left.processStartedAt === right.processStartedAt &&
102
+ left.protocolVersion === right.protocolVersion);
103
+ }
104
+ function activeResumePhase(run) {
105
+ if (run.phase === 'blocked')
106
+ return run.blocked?.resumePhase ?? 'pending_root';
107
+ if (isTerminalWorkflowPhase(run.phase)) {
108
+ throw new WorkflowTransitionError(`Workflow ${run.id} is ${run.phase}`);
109
+ }
110
+ return run.phase;
111
+ }
112
+ function guardJobs(jobs) {
113
+ return jobs.map(job => ({
114
+ role: job.role,
115
+ cycle: job.cycle,
116
+ status: job.state,
117
+ ...(isDeliveryReceipt(job.batonReceipt) ? { batonReceiptKind: job.batonReceipt.kind } : {})
118
+ }));
119
+ }
120
+ function actionOrdinal(jobs, role, cycle) {
121
+ const prefix = role === 'exploration' ? 'explore' : 'implement';
122
+ let max = -1;
123
+ for (const job of jobs) {
124
+ if (job.role !== role || job.cycle !== cycle)
125
+ continue;
126
+ if (job.logicalKey === 'explore:0' && cycle === 0)
127
+ max = Math.max(max, 0);
128
+ const match = job.logicalKey.match(new RegExp(`^${prefix}:${cycle}:(\\d+)$`));
129
+ if (match)
130
+ max = Math.max(max, Number(match[1]));
131
+ }
132
+ return max + 1;
133
+ }
134
+ function effectGrant(effect) {
135
+ if (!effect.inputs || typeof effect.inputs !== 'object')
136
+ return null;
137
+ const grant = effect.inputs.grant;
138
+ if (!grant ||
139
+ (grant.phase !== 'exploring' && grant.phase !== 'planning' && grant.phase !== 'reviewing') ||
140
+ !Number.isSafeInteger(grant.cycle) ||
141
+ !Number.isSafeInteger(grant.revision) ||
142
+ !Array.isArray(grant.allowedRoles) ||
143
+ grant.allowedRoles.some(role => role !== 'exploration' && role !== 'implementation')) {
144
+ return null;
145
+ }
146
+ return grant;
147
+ }
148
+ function assertWorkspaceReceipt(value) {
149
+ if (!value || typeof value !== 'object' || typeof value.workspaceId !== 'string') {
150
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', 'Workspace creation returned no exact workspace ID.');
151
+ }
152
+ return { workspaceId: value.workspaceId };
153
+ }
154
+ function assertSessionReceipt(value) {
155
+ if (!value || typeof value !== 'object' || typeof value.sessionId !== 'string') {
156
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', 'Child creation returned no exact session ID.');
157
+ }
158
+ return { sessionId: value.sessionId };
159
+ }
160
+ function assertDeliveryReceipt(value) {
161
+ if (!isDeliveryReceipt(value)) {
162
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', 'Prompt delivery returned no tagged receipt.');
163
+ }
164
+ return value;
165
+ }
166
+ /** DB roles originate exclusively in prepareWorkflowRun, whose effort union is narrower. */
167
+ function promptRole(role) {
168
+ return role;
169
+ }
170
+ export class WorkflowCoordinator {
171
+ db;
172
+ relay;
173
+ deps;
174
+ waking = new Map();
175
+ constructor(db, relay, deps) {
176
+ this.db = db;
177
+ this.relay = relay;
178
+ this.deps = deps;
179
+ this.db.registerRelayInstance({ ...relay, canDriveUi: true });
180
+ }
181
+ async start(input) {
182
+ const replay = this.db.getIdempotentMutation('start_workflow', input.clientId, {
183
+ objective: input.objective,
184
+ target: input.target
185
+ });
186
+ if (replay)
187
+ return { replayed: true, workflow: this.projection(replay.result.runId) };
188
+ this.heartbeat();
189
+ await this.assertCompatible();
190
+ const prepared = prepareWorkflowRun(input.roles, input.modelGroups, input.objective);
191
+ if (!prepared.ok) {
192
+ throw new WorkflowCoordinatorError(prepared.error.code, prepared.error.message, { status: 409 });
193
+ }
194
+ const runId = randomUUID();
195
+ const objective = prepared.prepared.objective;
196
+ const bootstrapPrompt = [
197
+ workflowBootstrapPrompt({
198
+ objective,
199
+ role: prepared.prepared.roles.exploration
200
+ }),
201
+ privateCorrelationBlock(runId, `job:${runId}:explore:0`)
202
+ ].join('\n\n');
203
+ let inspection = null;
204
+ let baseline;
205
+ if (input.target.kind === 'existing_session') {
206
+ inspection = await this.deps.inspectExistingRoot(input.target);
207
+ if (!inspection) {
208
+ throw new WorkflowCoordinatorError('workflow_not_found', 'The selected Workflow root no longer exists.', {
209
+ status: 404
210
+ });
211
+ }
212
+ if (inspection.workspaceId !== input.target.workspaceId || inspection.rootSessionId !== input.target.sessionId) {
213
+ throw new WorkflowCoordinatorError('workflow_not_found', 'The root inspection did not match the selected chat.', {
214
+ status: 404
215
+ });
216
+ }
217
+ if (!inspection.pristine) {
218
+ throw new WorkflowCoordinatorError('workflow_root_not_pristine', inspection.reason ?? 'Workflow requires a pristine root chat.', { status: 409 });
219
+ }
220
+ }
221
+ else {
222
+ baseline = cleanUnknown(await this.deps.captureWorkspaceBaseline(input.target.repo));
223
+ }
224
+ const accepted = this.db.createWorkflowRun({
225
+ id: runId,
226
+ clientId: input.clientId,
227
+ objective,
228
+ target: input.target,
229
+ roles: prepared.prepared.roles,
230
+ ...(inspection
231
+ ? {
232
+ workspaceId: inspection.workspaceId,
233
+ rootSessionId: inspection.rootSessionId,
234
+ pristineEvidence: cleanUnknown(inspection.pristineEvidence),
235
+ deliveryBaseline: inspection.deliveryCursor
236
+ }
237
+ : {}),
238
+ bootstrapPrompt,
239
+ initialEffects: input.target.kind === 'new_workspace'
240
+ ? [
241
+ {
242
+ actionId: 'create-workspace',
243
+ kind: 'create_workspace',
244
+ target: input.target,
245
+ inputs: { correlationMarker: workflowEffectCorrelationMarker(runId, 'create-workspace') },
246
+ baseline
247
+ }
248
+ ]
249
+ : [
250
+ {
251
+ actionId: 'configure-root',
252
+ kind: 'configure_root',
253
+ target: { workspaceId: input.target.workspaceId, sessionId: input.target.sessionId },
254
+ inputs: {
255
+ role: prepared.prepared.roles.planning,
256
+ correlationMarker: workflowEffectCorrelationMarker(runId, 'configure-root')
257
+ }
258
+ },
259
+ {
260
+ actionId: 'send-root',
261
+ kind: 'send_root',
262
+ target: { workspaceId: input.target.workspaceId, sessionId: input.target.sessionId },
263
+ cursor: inspection?.deliveryCursor,
264
+ inputs: {
265
+ cycle: 0,
266
+ revision: 0,
267
+ allowedRoles: ['exploration'],
268
+ correlationMarker: workflowEffectCorrelationMarker(runId, 'send-root')
269
+ }
270
+ }
271
+ ]
272
+ });
273
+ return { replayed: accepted.replayed, workflow: this.projection(accepted.run.id) };
274
+ }
275
+ wake(runId) {
276
+ const existing = this.waking.get(runId);
277
+ if (existing)
278
+ return existing;
279
+ const running = this.wakeInternal(runId).finally(() => this.waking.delete(runId));
280
+ this.waking.set(runId, running);
281
+ return running;
282
+ }
283
+ async wakeInternal(runId) {
284
+ this.heartbeat();
285
+ for (let step = 0; step < MAX_WAKE_STEPS; step++) {
286
+ const run = this.requireRun(runId);
287
+ if (run.phase === 'cancelled') {
288
+ await this.observeCancelledRun(run);
289
+ break;
290
+ }
291
+ if (run.phase === 'completed')
292
+ break;
293
+ if (run.phase === 'blocked') {
294
+ if (await this.reconcileBlockedEffect(run))
295
+ continue;
296
+ break;
297
+ }
298
+ try {
299
+ await this.assertCompatible();
300
+ }
301
+ catch (error) {
302
+ this.blockRun(run, {
303
+ actionId: `compatibility:${run.id}`,
304
+ errorCode: 'workflow_incompatible_relay',
305
+ message: errorMessage(error),
306
+ retryClass: 'deterministic'
307
+ });
308
+ break;
309
+ }
310
+ let changed = false;
311
+ try {
312
+ changed = await this.driveOnce(run);
313
+ }
314
+ catch (error) {
315
+ if (error instanceof WorkflowTransitionError) {
316
+ const current = this.requireRun(runId);
317
+ if (current.phase !== run.phase || current.cancellationGeneration !== run.cancellationGeneration)
318
+ continue;
319
+ }
320
+ throw error;
321
+ }
322
+ if (!changed)
323
+ break;
324
+ }
325
+ return this.projection(runId);
326
+ }
327
+ async observeCancelledRun(run) {
328
+ const observedEffects = new Map(this.db.listWorkflowEffects(run.id).map(effect => [effect.id, effect]));
329
+ for (const observed of observedEffects.values()) {
330
+ let effect = observed;
331
+ const sessionId = effectSessionId(effect);
332
+ if (sessionId && isDeliveryReceipt(effect.receipt) && effect.receipt.kind === 'outbox') {
333
+ const resolution = await this.deps.resolveDeliveryReceipt({
334
+ ...this.effectReadCall(run, effect),
335
+ sessionId,
336
+ receipt: effect.receipt
337
+ });
338
+ if (resolution.status === 'lost') {
339
+ this.markReceiptLost(run, effect, resolution.evidence);
340
+ continue;
341
+ }
342
+ if (resolution.status === 'delivered' && resolution.receipt.id === effect.receipt.id) {
343
+ effect = this.db.recordLateWorkflowEffect({
344
+ runId: run.id,
345
+ actionId: effect.actionId,
346
+ receipt: resolution.receipt,
347
+ eventKey: `late-effect:${effect.actionId}:message:${resolution.receipt.id}`
348
+ });
349
+ observedEffects.set(effect.id, effect);
350
+ }
351
+ }
352
+ if (effect.state !== 'dispatched' && effect.state !== 'ambiguous')
353
+ continue;
354
+ const reconciliation = await this.deps.reconcileEffect?.(this.effectReadCall(run, effect));
355
+ if (reconciliation?.status === 'committed') {
356
+ const receipt = cleanUnknown(reconciliation.receipt);
357
+ effect = this.db.recordLateWorkflowEffect({
358
+ runId: run.id,
359
+ actionId: effect.actionId,
360
+ receipt,
361
+ eventKey: `late-effect:${effect.actionId}:${receiptEventSuffix(receipt, `attempt:${effect.attemptCount}`)}`
362
+ });
363
+ observedEffects.set(effect.id, effect);
364
+ continue;
365
+ }
366
+ if (effect.state === 'dispatched' && effect.mayExecute) {
367
+ const recovery = this.db.reconcileAbandonedWorkflowEffect({
368
+ runId: run.id,
369
+ actionId: effect.actionId,
370
+ eventKey: `late-effect-recovery:${effect.actionId}:${effect.attemptCount}`
371
+ });
372
+ if (recovery.status !== 'ambiguous')
373
+ continue;
374
+ const ambiguous = recovery.effect;
375
+ this.db.activateUiQuarantine({
376
+ actionId: ambiguous.actionId,
377
+ effectId: ambiguous.id,
378
+ reason: ambiguous.errorMessage ?? 'A cancelled Workflow effect remains ambiguous.',
379
+ owner: ambiguous.owner,
380
+ externalProcess: ambiguous.externalProcess
381
+ });
382
+ }
383
+ }
384
+ for (const job of this.db.listWorkflowJobs(run.id)) {
385
+ if (job.state !== 'cancelled' || !job.childSessionId || job.outcome !== undefined)
386
+ continue;
387
+ const taskEffect = [...observedEffects.values()].find(effect => effect.jobId === job.id && effect.kind === 'send_task');
388
+ const taskReceipt = isDeliveryReceipt(job.taskReceipt) && job.taskReceipt.kind === 'message'
389
+ ? job.taskReceipt
390
+ : isDeliveryReceipt(taskEffect?.receipt) && taskEffect.receipt.kind === 'message'
391
+ ? taskEffect.receipt
392
+ : undefined;
393
+ if (!taskReceipt)
394
+ continue;
395
+ const outcome = await this.deps.readChildOutcome({
396
+ run,
397
+ job: job.taskReceipt === taskReceipt ? job : { ...job, taskReceipt }
398
+ });
399
+ if (!outcome)
400
+ continue;
401
+ this.db.recordLateWorkflowChildResult({
402
+ runId: run.id,
403
+ jobId: job.id,
404
+ attemptNumber: job.attemptCount,
405
+ outcome: sanitizeChildOutcome(outcome),
406
+ eventKey: `late-child:${job.id}:${job.attemptCount}`
407
+ });
408
+ }
409
+ }
410
+ async driveOnce(run) {
411
+ if (run.phase === 'creating_workspace')
412
+ return this.driveWorkspaceCreation(run);
413
+ if (run.phase === 'binding_root')
414
+ return this.driveRootBinding(run);
415
+ if (run.phase === 'pending_root')
416
+ return this.drivePendingRoot(run);
417
+ if (run.phase === 'exploring' ||
418
+ run.phase === 'planning' ||
419
+ run.phase === 'implementing' ||
420
+ run.phase === 'reviewing') {
421
+ if (await this.drivePhaseAuthorization(run))
422
+ return true;
423
+ if (this.advanceDeliveredBarrier(run))
424
+ return true;
425
+ if (run.phase === 'planning' || run.phase === 'reviewing')
426
+ return false;
427
+ return this.driveNextJob(run);
428
+ }
429
+ return false;
430
+ }
431
+ async driveWorkspaceCreation(run) {
432
+ const effect = this.requireEffect(run.id, 'create-workspace');
433
+ const result = await this.runDurableEffect({
434
+ run,
435
+ effect,
436
+ execute: (_token, dispatch) => this.deps.createWorkspace({
437
+ ...this.effectCall(run, effect, dispatch),
438
+ target: run.target
439
+ }),
440
+ validate: assertWorkspaceReceipt
441
+ });
442
+ const committed = result.effect.state === 'committed' ? result.effect : this.requireEffect(run.id, effect.actionId);
443
+ if (committed.state !== 'committed')
444
+ return result.changed;
445
+ const receipt = assertWorkspaceReceipt(committed.receipt);
446
+ const fresh = this.requireRun(run.id);
447
+ if (fresh.phase !== 'creating_workspace')
448
+ return true;
449
+ this.db.transitionWorkflowRun({
450
+ runId: fresh.id,
451
+ expectedPhase: 'creating_workspace',
452
+ expectedCancellationGeneration: fresh.cancellationGeneration,
453
+ phase: 'binding_root',
454
+ workspaceId: receipt.workspaceId,
455
+ eventKey: `workspace-created:${receipt.workspaceId}`,
456
+ eventType: 'workflow_workspace_created',
457
+ eventData: receipt
458
+ });
459
+ return true;
460
+ }
461
+ async driveRootBinding(run) {
462
+ if (!run.workspaceId)
463
+ throw new WorkflowTransitionError(`Workflow ${run.id} has no created workspace binding`);
464
+ const inspection = await this.deps.bindCreatedRoot({ run, workspaceId: run.workspaceId });
465
+ if (!inspection)
466
+ return false;
467
+ if (inspection.workspaceId !== run.workspaceId) {
468
+ this.blockRun(run, {
469
+ actionId: 'bind-root',
470
+ errorCode: 'workflow_root_mismatch',
471
+ message: 'The discovered root did not belong to the exact created workspace.',
472
+ retryClass: 'terminal'
473
+ });
474
+ return true;
475
+ }
476
+ if (!inspection.pristine) {
477
+ this.blockRun(run, {
478
+ actionId: 'bind-root',
479
+ errorCode: 'workflow_root_not_pristine',
480
+ message: inspection.reason ?? 'The created root chat is no longer pristine.',
481
+ retryClass: 'terminal'
482
+ });
483
+ return true;
484
+ }
485
+ this.db.idempotentMutation('bind_workflow_root', `${run.id}:${inspection.workspaceId}:${inspection.rootSessionId}`, {
486
+ workspaceId: inspection.workspaceId,
487
+ rootSessionId: inspection.rootSessionId,
488
+ pristineEvidence: cleanUnknown(inspection.pristineEvidence),
489
+ deliveryCursor: inspection.deliveryCursor
490
+ }, () => {
491
+ this.db.transitionWorkflowRun({
492
+ runId: run.id,
493
+ expectedPhase: 'binding_root',
494
+ expectedCancellationGeneration: run.cancellationGeneration,
495
+ phase: 'pending_root',
496
+ rootSessionId: inspection.rootSessionId,
497
+ pristineEvidence: cleanUnknown(inspection.pristineEvidence),
498
+ deliveryBaseline: inspection.deliveryCursor,
499
+ eventKey: `root-bound:${inspection.rootSessionId}`,
500
+ eventType: 'workflow_root_bound'
501
+ });
502
+ this.prepareRootEffects(this.requireRun(run.id), inspection.deliveryCursor);
503
+ return { runId: run.id };
504
+ }, { runId: run.id });
505
+ return true;
506
+ }
507
+ prepareRootEffects(run, cursor) {
508
+ if (!run.workspaceId || !run.rootSessionId)
509
+ throw new WorkflowTransitionError('Workflow root is not fully bound');
510
+ this.db.prepareWorkflowEffect({
511
+ runId: run.id,
512
+ actionId: 'configure-root',
513
+ kind: 'configure_root',
514
+ target: { workspaceId: run.workspaceId, sessionId: run.rootSessionId },
515
+ inputs: {
516
+ role: run.roles.planning,
517
+ correlationMarker: workflowEffectCorrelationMarker(run.id, 'configure-root')
518
+ },
519
+ expectedCancellationGeneration: run.cancellationGeneration,
520
+ eventKey: 'prepare:configure-root'
521
+ });
522
+ this.db.prepareWorkflowEffect({
523
+ runId: run.id,
524
+ actionId: 'send-root',
525
+ kind: 'send_root',
526
+ target: { workspaceId: run.workspaceId, sessionId: run.rootSessionId },
527
+ inputs: {
528
+ cycle: 0,
529
+ revision: 0,
530
+ allowedRoles: ['exploration'],
531
+ correlationMarker: workflowEffectCorrelationMarker(run.id, 'send-root')
532
+ },
533
+ cursor,
534
+ expectedCancellationGeneration: run.cancellationGeneration,
535
+ eventKey: 'prepare:send-root'
536
+ });
537
+ }
538
+ async drivePendingRoot(run) {
539
+ if (!run.rootSessionId || !run.workspaceId)
540
+ throw new WorkflowTransitionError('pending root is not bound');
541
+ let configure = this.db.getWorkflowEffect(run.id, 'configure-root');
542
+ let send = this.db.getWorkflowEffect(run.id, 'send-root');
543
+ if (!configure || !send) {
544
+ this.prepareRootEffects(run, run.deliveryBaseline);
545
+ configure = this.requireEffect(run.id, 'configure-root');
546
+ send = this.requireEffect(run.id, 'send-root');
547
+ }
548
+ if (configure.state !== 'committed') {
549
+ const result = await this.runDurableEffect({
550
+ run,
551
+ effect: configure,
552
+ execute: (_token, dispatch) => this.deps.configureSession({
553
+ ...this.effectCall(run, configure, dispatch),
554
+ sessionId: run.rootSessionId,
555
+ role: run.roles.planning
556
+ }),
557
+ validate: result => cleanUnknown(result ?? { matched: true })
558
+ });
559
+ return result.changed;
560
+ }
561
+ if (send.state !== 'committed') {
562
+ const grant = {
563
+ phase: 'exploring',
564
+ cycle: 0,
565
+ revision: 0,
566
+ allowedRoles: ['exploration']
567
+ };
568
+ const result = await this.runDurableEffect({
569
+ run,
570
+ effect: send,
571
+ capabilityGrant: grant,
572
+ execute: (token, dispatch) => this.deps.sendPrompt({
573
+ ...this.effectCall(run, send, dispatch),
574
+ sessionId: run.rootSessionId,
575
+ text: [
576
+ workflowRootPrompt({
577
+ workflowId: run.id,
578
+ objective: run.objective,
579
+ role: promptRole(run.roles.planning),
580
+ phaseCapability: token,
581
+ cycle: 0,
582
+ revision: 0
583
+ }),
584
+ privateCorrelationBlock(run.id, send.actionId)
585
+ ].join('\n\n')
586
+ }),
587
+ validate: assertDeliveryReceipt
588
+ });
589
+ if (result.effect.state !== 'committed')
590
+ return result.changed;
591
+ return (await this.activateRootFromReceipt(run, result.effect)) || result.changed;
592
+ }
593
+ return this.activateRootFromReceipt(run, send);
594
+ }
595
+ async activateRootFromReceipt(run, effect) {
596
+ if (!run.rootSessionId || !isDeliveryReceipt(effect.receipt))
597
+ return false;
598
+ const resolution = await this.deps.resolveDeliveryReceipt({
599
+ ...this.effectReadCall(run, effect),
600
+ sessionId: run.rootSessionId,
601
+ receipt: effect.receipt
602
+ });
603
+ if (resolution.status === 'lost') {
604
+ this.markReceiptLost(run, effect, resolution.evidence);
605
+ return true;
606
+ }
607
+ const resolved = messageReceipt(resolution, effect.receipt.id);
608
+ if (!resolved)
609
+ return false;
610
+ if (!effect.launchNonce || !/^[a-f\d]{64}$/i.test(effect.launchNonce)) {
611
+ throw new WorkflowTransitionError('delivered root effect has no capability hash');
612
+ }
613
+ this.db.idempotentMutation('activate_workflow_root', `${run.id}:${resolved.id}`, { receipt: resolved, tokenHash: effect.launchNonce }, () => {
614
+ const current = this.requireRun(run.id);
615
+ if (current.phase !== 'pending_root')
616
+ return { runId: run.id };
617
+ const exploring = this.db.transitionWorkflowRun({
618
+ runId: current.id,
619
+ expectedPhase: 'pending_root',
620
+ expectedCancellationGeneration: current.cancellationGeneration,
621
+ phase: 'exploring',
622
+ cycle: 0,
623
+ revision: 0,
624
+ eventKey: `root-delivered:${resolved.id}`,
625
+ eventType: 'workflow_root_delivered',
626
+ eventData: resolved
627
+ });
628
+ this.db.issueWorkflowCapability({
629
+ tokenHash: effect.launchNonce,
630
+ runId: exploring.id,
631
+ rootSessionId: exploring.rootSessionId,
632
+ cycle: exploring.cycle,
633
+ phase: 'exploring',
634
+ revision: exploring.revision,
635
+ allowedRoles: ['exploration'],
636
+ issuedWith: { rowid: resolved.rowid, ...(resolved.turnId ? { turnId: resolved.turnId } : {}) },
637
+ eventKey: `root-capability:${resolved.id}`
638
+ });
639
+ this.db.activateWorkflowJob(`${run.id}:explore:0`, exploring.cancellationGeneration, `bootstrap-activated:${resolved.id}`, { rowid: resolved.rowid });
640
+ return { runId: run.id };
641
+ }, { runId: run.id, actionId: effect.actionId });
642
+ return true;
643
+ }
644
+ async drivePhaseAuthorization(run) {
645
+ if (run.phase !== 'exploring' || !run.rootSessionId)
646
+ return false;
647
+ const actionId = `authorize:exploring:${run.cycle}:${run.revision}`;
648
+ const effect = this.db.getWorkflowEffect(run.id, actionId);
649
+ if (!effect)
650
+ return false;
651
+ const grant = effectGrant(effect);
652
+ if (!grant)
653
+ throw new WorkflowTransitionError(`authorization effect ${actionId} has invalid grant inputs`);
654
+ let current = effect;
655
+ let changed = false;
656
+ if (effect.state !== 'committed') {
657
+ const result = await this.runDurableEffect({
658
+ run,
659
+ effect,
660
+ capabilityGrant: grant,
661
+ execute: (token, dispatch) => this.deps.sendPrompt({
662
+ ...this.effectCall(run, effect, dispatch),
663
+ sessionId: run.rootSessionId,
664
+ text: [
665
+ 'Workflow exploration authorization rotated after accepting a tracked explorer.',
666
+ 'Do not repeat or quote the private block. Use it only for another delegate_task call.',
667
+ workflowPrivateEnvelope({
668
+ workflowId: run.id,
669
+ phaseCapability: token,
670
+ cycle: grant.cycle,
671
+ revision: grant.revision,
672
+ allowedRoles: grant.allowedRoles
673
+ }),
674
+ privateCorrelationBlock(run.id, effect.actionId)
675
+ ].join('\n\n')
676
+ }),
677
+ validate: assertDeliveryReceipt
678
+ });
679
+ current = result.effect;
680
+ changed = result.changed;
681
+ }
682
+ if (current.state !== 'committed' || !isDeliveryReceipt(current.receipt))
683
+ return changed;
684
+ const resolution = await this.deps.resolveDeliveryReceipt({
685
+ ...this.effectReadCall(run, current),
686
+ sessionId: run.rootSessionId,
687
+ receipt: current.receipt
688
+ });
689
+ if (resolution.status === 'lost') {
690
+ this.markReceiptLost(run, current, resolution.evidence);
691
+ return true;
692
+ }
693
+ const receipt = messageReceipt(resolution, current.receipt.id);
694
+ if (!receipt)
695
+ return changed;
696
+ if (!current.launchNonce)
697
+ throw new WorkflowTransitionError('authorization effect has no capability hash');
698
+ const fresh = this.requireRun(run.id);
699
+ if (fresh.phase !== grant.phase || fresh.cycle !== grant.cycle || fresh.revision !== grant.revision)
700
+ return changed;
701
+ const issuance = this.db.idempotentMutation('issue_workflow_phase_capability', `${run.id}:${current.actionId}:${receipt.id}`, { actionId: current.actionId, receipt, tokenHash: current.launchNonce }, () => {
702
+ this.db.issueWorkflowCapability({
703
+ tokenHash: current.launchNonce,
704
+ runId: fresh.id,
705
+ rootSessionId: fresh.rootSessionId,
706
+ cycle: grant.cycle,
707
+ phase: grant.phase,
708
+ revision: grant.revision,
709
+ allowedRoles: grant.allowedRoles,
710
+ issuedWith: { rowid: receipt.rowid, ...(receipt.turnId ? { turnId: receipt.turnId } : {}) },
711
+ eventKey: `phase-capability:${current.actionId}:${receipt.id}`
712
+ });
713
+ return { runId: run.id };
714
+ }, { runId: run.id, actionId: current.actionId });
715
+ return changed || !issuance.replayed;
716
+ }
717
+ async driveNextJob(run) {
718
+ let jobs = this.db
719
+ .listWorkflowJobs(run.id)
720
+ .filter(job => job.cycle === run.cycle && job.role === (run.phase === 'exploring' ? 'exploration' : 'implementation'));
721
+ const queued = jobs.find(job => job.state === 'queued');
722
+ if (queued)
723
+ return Boolean(this.db.claimNextWorkflowJob(this.relay, run.id));
724
+ const owned = jobs.find(job => job.state === 'owned');
725
+ if (owned) {
726
+ if (!sameRelay(owned.owner, this.relay)) {
727
+ const recovered = this.db.reconcileAbandonedWorkflowJobClaim({
728
+ jobId: owned.id,
729
+ eventKey: `recover-job-claim:${owned.id}:${owned.attemptCount}`
730
+ });
731
+ return recovered.status === 'requeued';
732
+ }
733
+ await this.beginJobAttempt(run, owned);
734
+ return true;
735
+ }
736
+ jobs = this.db.listWorkflowJobs(run.id).filter(job => job.cycle === run.cycle);
737
+ for (const active of jobs.filter(job => !isTerminalWorkflowJobState(job.state) && job.state !== 'dormant')) {
738
+ if (await this.driveJob(run, active))
739
+ return true;
740
+ }
741
+ return false;
742
+ }
743
+ async beginJobAttempt(run, job) {
744
+ if (!run.workspaceId)
745
+ throw new WorkflowTransitionError(`Workflow ${run.id} has no workspace for child creation`);
746
+ const sessionBaseline = cleanUnknown(await this.deps.captureSessionBaseline(run.workspaceId));
747
+ const latest = this.db.getWorkflowJob(job.id);
748
+ if (latest?.state !== 'owned' || !sameRelay(latest.owner, this.relay))
749
+ return;
750
+ const attemptNumber = job.attemptCount + 1;
751
+ const openAction = `${job.id}:open:${attemptNumber}`;
752
+ const configureAction = `${job.id}:configure:${attemptNumber}`;
753
+ const taskAction = `${job.id}:task:${attemptNumber}`;
754
+ const batonAction = `${job.id}:baton:${attemptNumber}`;
755
+ this.db.idempotentMutation('begin_workflow_job_attempt', `${run.id}:${job.id}:${attemptNumber}`, { jobId: job.id, attemptNumber, openAction, sessionBaseline }, () => {
756
+ this.db.createWorkflowJobAttempt({
757
+ jobId: job.id,
758
+ owner: this.relay,
759
+ state: 'opening',
760
+ effectIds: {
761
+ open: `${run.id}:${openAction}`,
762
+ configure: `${run.id}:${configureAction}`,
763
+ task: `${run.id}:${taskAction}`,
764
+ baton: `${run.id}:${batonAction}`
765
+ }
766
+ });
767
+ this.db.prepareWorkflowEffect({
768
+ id: `${run.id}:${openAction}`,
769
+ runId: run.id,
770
+ actionId: openAction,
771
+ kind: 'open_child',
772
+ jobId: job.id,
773
+ target: { workspaceId: run.workspaceId, rootSessionId: run.rootSessionId },
774
+ inputs: { correlationMarker: workflowEffectCorrelationMarker(run.id, openAction) },
775
+ baseline: sessionBaseline,
776
+ expectedCancellationGeneration: run.cancellationGeneration,
777
+ eventKey: `prepare:${openAction}`
778
+ });
779
+ this.db.updateWorkflowJob({
780
+ jobId: job.id,
781
+ expectedStates: ['owned'],
782
+ expectedCancellationGeneration: run.cancellationGeneration,
783
+ state: 'opening',
784
+ eventKey: `job-opening:${job.id}:${attemptNumber}`,
785
+ eventType: 'workflow_job_opening'
786
+ });
787
+ return { runId: run.id, jobId: job.id };
788
+ }, { runId: run.id, actionId: openAction });
789
+ }
790
+ async driveJob(run, job) {
791
+ if (job.state === 'opening')
792
+ return this.driveJobOpen(run, job);
793
+ if (job.state === 'configuring')
794
+ return this.driveJobConfigure(run, job);
795
+ if (job.state === 'sending')
796
+ return this.driveJobSend(run, job);
797
+ if (job.state === 'running')
798
+ return this.driveJobOutcome(run, job);
799
+ if (job.state === 'returning')
800
+ return this.driveJobBaton(run, job);
801
+ return false;
802
+ }
803
+ async driveJobOpen(run, job) {
804
+ const actionId = `${job.id}:open:${job.attemptCount}`;
805
+ const effect = this.requireEffect(run.id, actionId);
806
+ const result = await this.runDurableEffect({
807
+ run,
808
+ effect,
809
+ job,
810
+ execute: (_token, dispatch) => this.deps.openChild({ ...this.effectCall(run, effect, dispatch, job), job }),
811
+ validate: assertSessionReceipt
812
+ });
813
+ const current = result.effect.state === 'committed' ? result.effect : this.requireEffect(run.id, actionId);
814
+ if (current.state !== 'committed')
815
+ return result.changed;
816
+ const receipt = assertSessionReceipt(current.receipt);
817
+ const freshJob = this.db.getWorkflowJob(job.id);
818
+ if (freshJob?.state !== 'opening')
819
+ return true;
820
+ this.db.idempotentMutation('workflow_job_opened', `${job.id}:${job.attemptCount}:${receipt.sessionId}`, receipt, () => {
821
+ this.db.updateWorkflowJobAttempt({
822
+ jobId: job.id,
823
+ attemptNumber: job.attemptCount,
824
+ expectedState: 'opening',
825
+ state: 'configuring',
826
+ childSessionId: receipt.sessionId,
827
+ eventKey: `attempt-configuring:${job.id}:${job.attemptCount}`,
828
+ eventType: 'workflow_job_attempt_configuring'
829
+ });
830
+ this.db.updateWorkflowJob({
831
+ jobId: job.id,
832
+ expectedStates: ['opening'],
833
+ expectedCancellationGeneration: run.cancellationGeneration,
834
+ state: 'configuring',
835
+ childSessionId: receipt.sessionId,
836
+ eventKey: `job-configuring:${job.id}:${job.attemptCount}`,
837
+ eventType: 'workflow_job_configuring'
838
+ });
839
+ return { runId: run.id, jobId: job.id };
840
+ }, { runId: run.id, actionId });
841
+ return true;
842
+ }
843
+ async driveJobConfigure(run, job) {
844
+ if (!job.childSessionId)
845
+ throw new WorkflowTransitionError(`job ${job.id} is configuring without a child`);
846
+ const actionId = `${job.id}:configure:${job.attemptCount}`;
847
+ let effect = this.db.getWorkflowEffect(run.id, actionId);
848
+ if (!effect) {
849
+ effect = this.db.prepareWorkflowEffect({
850
+ id: `${run.id}:${actionId}`,
851
+ runId: run.id,
852
+ actionId,
853
+ kind: 'configure_child',
854
+ jobId: job.id,
855
+ target: { sessionId: job.childSessionId },
856
+ inputs: {
857
+ role: job.resolvedRole,
858
+ correlationMarker: workflowEffectCorrelationMarker(run.id, actionId)
859
+ },
860
+ expectedCancellationGeneration: run.cancellationGeneration,
861
+ eventKey: `prepare:${actionId}`
862
+ }).effect;
863
+ }
864
+ const result = await this.runDurableEffect({
865
+ run,
866
+ effect,
867
+ job,
868
+ execute: (_token, dispatch) => this.deps.configureSession({
869
+ ...this.effectCall(run, effect, dispatch, job),
870
+ sessionId: job.childSessionId,
871
+ role: job.resolvedRole
872
+ }),
873
+ validate: value => cleanUnknown(value ?? { matched: true })
874
+ });
875
+ const current = result.effect.state === 'committed' ? result.effect : this.requireEffect(run.id, actionId);
876
+ if (current.state !== 'committed')
877
+ return result.changed;
878
+ const freshJob = this.db.getWorkflowJob(job.id);
879
+ if (freshJob?.state !== 'configuring')
880
+ return true;
881
+ const taskAction = `${job.id}:task:${job.attemptCount}`;
882
+ const existingTask = this.db.getWorkflowEffect(run.id, taskAction);
883
+ if (existingTask) {
884
+ if (existingTask.kind !== 'send_task' || existingTask.jobId !== job.id) {
885
+ throw new WorkflowTransitionError(`task effect ${taskAction} does not match job ${job.id}`);
886
+ }
887
+ const prompt = existingTask.inputs &&
888
+ typeof existingTask.inputs === 'object' &&
889
+ typeof existingTask.inputs.prompt === 'string'
890
+ ? existingTask.inputs.prompt
891
+ : undefined;
892
+ if (!prompt)
893
+ throw new WorkflowTransitionError(`task effect ${taskAction} has no frozen prompt`);
894
+ this.finishJobConfiguration(run, job, taskAction, existingTask.cursor, prompt, false);
895
+ return true;
896
+ }
897
+ const cursor = await this.deps.captureDeliveryCursor(job.childSessionId);
898
+ const handoff = scrubWorkflowSecrets((await this.deps.materializeHandoff?.({ run, job })) ?? '');
899
+ const basePrompt = handoff ? `${job.prompt}\n\nSanitized root handoff: ${handoff}` : job.prompt;
900
+ const prompt = `${basePrompt}\n\n${privateCorrelationBlock(run.id, taskAction)}`;
901
+ this.finishJobConfiguration(run, job, taskAction, cursor, prompt, true);
902
+ return true;
903
+ }
904
+ finishJobConfiguration(run, job, taskAction, cursor, prompt, prepareTask) {
905
+ this.db.idempotentMutation('workflow_job_configured', `${job.id}:${job.attemptCount}`, { cursor, prompt }, () => {
906
+ if (prepareTask) {
907
+ this.db.prepareWorkflowEffect({
908
+ id: `${run.id}:${taskAction}`,
909
+ runId: run.id,
910
+ actionId: taskAction,
911
+ kind: 'send_task',
912
+ jobId: job.id,
913
+ target: { sessionId: job.childSessionId },
914
+ inputs: {
915
+ prompt,
916
+ correlationMarker: workflowEffectCorrelationMarker(run.id, taskAction)
917
+ },
918
+ cursor,
919
+ expectedCancellationGeneration: run.cancellationGeneration,
920
+ eventKey: `prepare:${taskAction}`
921
+ });
922
+ }
923
+ this.db.updateWorkflowJobAttempt({
924
+ jobId: job.id,
925
+ attemptNumber: job.attemptCount,
926
+ expectedState: 'configuring',
927
+ state: 'sending',
928
+ eventKey: `attempt-sending:${job.id}:${job.attemptCount}`,
929
+ eventType: 'workflow_job_attempt_sending'
930
+ });
931
+ this.db.updateWorkflowJob({
932
+ jobId: job.id,
933
+ expectedStates: ['configuring'],
934
+ expectedCancellationGeneration: run.cancellationGeneration,
935
+ state: 'sending',
936
+ eventKey: `job-sending:${job.id}:${job.attemptCount}`,
937
+ eventType: 'workflow_job_sending'
938
+ });
939
+ return { runId: run.id, jobId: job.id };
940
+ }, { runId: run.id, actionId: taskAction });
941
+ }
942
+ async driveJobSend(run, job) {
943
+ if (!job.childSessionId)
944
+ throw new WorkflowTransitionError(`job ${job.id} is sending without a child`);
945
+ const actionId = `${job.id}:task:${job.attemptCount}`;
946
+ const effect = this.requireEffect(run.id, actionId);
947
+ let current = effect;
948
+ let changed = false;
949
+ if (effect.state !== 'committed') {
950
+ const prompt = effect.inputs &&
951
+ typeof effect.inputs === 'object' &&
952
+ typeof effect.inputs.prompt === 'string'
953
+ ? effect.inputs.prompt
954
+ : job.prompt;
955
+ const result = await this.runDurableEffect({
956
+ run,
957
+ effect,
958
+ job,
959
+ execute: (_token, dispatch) => this.deps.sendPrompt({
960
+ ...this.effectCall(run, effect, dispatch, job),
961
+ sessionId: job.childSessionId,
962
+ text: prompt
963
+ }),
964
+ validate: assertDeliveryReceipt
965
+ });
966
+ current = result.effect;
967
+ changed = result.changed;
968
+ }
969
+ if (current.state !== 'committed' || !isDeliveryReceipt(current.receipt))
970
+ return changed;
971
+ const latestJob = this.db.getWorkflowJob(job.id);
972
+ if (latestJob?.state !== 'sending')
973
+ return true;
974
+ const resolution = await this.deps.resolveDeliveryReceipt({
975
+ ...this.effectReadCall(run, current, job),
976
+ sessionId: job.childSessionId,
977
+ receipt: current.receipt
978
+ });
979
+ if (resolution.status === 'lost') {
980
+ this.markReceiptLost(run, current, resolution.evidence);
981
+ return true;
982
+ }
983
+ const resolved = messageReceipt(resolution, current.receipt.id);
984
+ if (!resolved) {
985
+ if (!isDeliveryReceipt(latestJob.taskReceipt)) {
986
+ this.db.updateWorkflowJob({
987
+ jobId: job.id,
988
+ expectedStates: ['sending'],
989
+ expectedCancellationGeneration: run.cancellationGeneration,
990
+ state: 'sending',
991
+ taskReceipt: current.receipt,
992
+ eventKey: `task-accepted:${job.id}:${current.receipt.id}`,
993
+ eventType: 'workflow_task_accepted'
994
+ });
995
+ return true;
996
+ }
997
+ return changed;
998
+ }
999
+ this.db.idempotentMutation('workflow_task_delivered', `${job.id}:${resolved.id}`, resolved, () => {
1000
+ this.db.updateWorkflowJobAttempt({
1001
+ jobId: job.id,
1002
+ attemptNumber: job.attemptCount,
1003
+ expectedState: 'sending',
1004
+ state: 'running',
1005
+ eventKey: `attempt-running:${job.id}:${job.attemptCount}`,
1006
+ eventType: 'workflow_job_attempt_running'
1007
+ });
1008
+ this.db.updateWorkflowJob({
1009
+ jobId: job.id,
1010
+ expectedStates: ['sending'],
1011
+ expectedCancellationGeneration: run.cancellationGeneration,
1012
+ state: 'running',
1013
+ taskReceipt: resolved,
1014
+ eventKey: `task-delivered:${job.id}:${resolved.id}`,
1015
+ eventType: 'workflow_task_delivered'
1016
+ });
1017
+ return { runId: run.id, jobId: job.id };
1018
+ }, { runId: run.id, actionId });
1019
+ return true;
1020
+ }
1021
+ async driveJobOutcome(run, job) {
1022
+ const outcome = await this.deps.readChildOutcome({ run, job });
1023
+ if (!outcome)
1024
+ return false;
1025
+ const sanitized = sanitizeChildOutcome(outcome);
1026
+ const freshRun = this.requireRun(run.id);
1027
+ if (freshRun.phase === 'cancelled') {
1028
+ this.db.recordLateWorkflowChildResult({
1029
+ runId: freshRun.id,
1030
+ jobId: job.id,
1031
+ attemptNumber: job.attemptCount,
1032
+ outcome: sanitized,
1033
+ eventKey: `late-child:${job.id}:${job.attemptCount}`
1034
+ });
1035
+ return true;
1036
+ }
1037
+ if (sanitized.kind === 'failure') {
1038
+ this.db.idempotentMutation('workflow_job_failed', `${job.id}:${job.attemptCount}`, sanitized, () => {
1039
+ this.db.updateWorkflowJobAttempt({
1040
+ jobId: job.id,
1041
+ attemptNumber: job.attemptCount,
1042
+ expectedState: 'running',
1043
+ state: 'failed',
1044
+ outcome: sanitized,
1045
+ failureEvidence: sanitized.evidence,
1046
+ eventKey: `attempt-failed:${job.id}:${job.attemptCount}`,
1047
+ eventType: 'workflow_job_attempt_failed'
1048
+ });
1049
+ this.db.updateWorkflowJob({
1050
+ jobId: job.id,
1051
+ expectedStates: ['running'],
1052
+ expectedCancellationGeneration: run.cancellationGeneration,
1053
+ state: 'failed',
1054
+ outcome: sanitized,
1055
+ clearOwner: true,
1056
+ eventKey: `job-failed:${job.id}:${job.attemptCount}`,
1057
+ eventType: 'workflow_job_failed'
1058
+ });
1059
+ this.blockRun(this.requireRun(run.id), {
1060
+ actionId: `job:${job.id}`,
1061
+ errorCode: sanitized.code,
1062
+ message: sanitized.message,
1063
+ retryClass: sanitized.retryClass ?? 'deterministic'
1064
+ });
1065
+ return { runId: run.id, jobId: job.id };
1066
+ }, { runId: run.id, actionId: `job:${job.id}` });
1067
+ return true;
1068
+ }
1069
+ this.db.idempotentMutation('workflow_job_outcome', `${job.id}:${job.attemptCount}`, sanitized, () => {
1070
+ this.db.updateWorkflowJobAttempt({
1071
+ jobId: job.id,
1072
+ attemptNumber: job.attemptCount,
1073
+ expectedState: 'running',
1074
+ state: 'returning',
1075
+ outcome: sanitized,
1076
+ eventKey: `attempt-returning:${job.id}:${job.attemptCount}`,
1077
+ eventType: 'workflow_job_attempt_returning'
1078
+ });
1079
+ this.db.updateWorkflowJob({
1080
+ jobId: job.id,
1081
+ expectedStates: ['running'],
1082
+ expectedCancellationGeneration: run.cancellationGeneration,
1083
+ state: 'returning',
1084
+ outcome: sanitized,
1085
+ eventKey: `job-returning:${job.id}:${job.attemptCount}`,
1086
+ eventType: 'workflow_job_returning'
1087
+ });
1088
+ return { runId: run.id, jobId: job.id };
1089
+ }, { runId: run.id, actionId: `job:${job.id}:outcome` });
1090
+ return true;
1091
+ }
1092
+ potentialBatonGrant(run, job) {
1093
+ const peers = this.db
1094
+ .listWorkflowJobs(run.id)
1095
+ .filter(peer => peer.cycle === job.cycle && peer.role === job.role && peer.id !== job.id);
1096
+ const peersDelivered = peers.every(peer => peer.state === 'returned' && isDeliveryReceipt(peer.batonReceipt) && peer.batonReceipt.kind === 'message');
1097
+ if (!peersDelivered)
1098
+ return undefined;
1099
+ return {
1100
+ phase: job.role === 'exploration' ? 'planning' : 'reviewing',
1101
+ cycle: run.cycle,
1102
+ revision: run.revision + 1,
1103
+ allowedRoles: ['exploration', 'implementation']
1104
+ };
1105
+ }
1106
+ async driveJobBaton(run, job) {
1107
+ if (!run.rootSessionId)
1108
+ throw new WorkflowTransitionError(`Workflow ${run.id} has no root for Baton return`);
1109
+ const outcome = job.outcome;
1110
+ if (outcome?.kind !== 'success')
1111
+ throw new WorkflowTransitionError(`job ${job.id} has no successful outcome`);
1112
+ const actionId = `${job.id}:baton:${job.attemptCount}`;
1113
+ let effect = this.db.getWorkflowEffect(run.id, actionId);
1114
+ if (!effect) {
1115
+ const roleJobs = this.db
1116
+ .listWorkflowJobs(run.id)
1117
+ .filter(peer => peer.cycle === job.cycle && peer.role === job.role);
1118
+ const designatedFinal = roleJobs.at(-1)?.id === job.id;
1119
+ const grant = designatedFinal ? this.potentialBatonGrant(run, job) : undefined;
1120
+ // Exactly one current-cycle Baton carries the next phase capability. Hold
1121
+ // that deterministic final job until every earlier sibling Baton is a
1122
+ // durable root message; accepted outbox rows do not satisfy the barrier.
1123
+ if (designatedFinal && !grant)
1124
+ return false;
1125
+ effect = this.db.prepareWorkflowEffect({
1126
+ id: `${run.id}:${actionId}`,
1127
+ runId: run.id,
1128
+ actionId,
1129
+ kind: 'return_baton',
1130
+ jobId: job.id,
1131
+ target: { sessionId: run.rootSessionId },
1132
+ inputs: {
1133
+ ...(grant ? { grant } : {}),
1134
+ correlationMarker: workflowEffectCorrelationMarker(run.id, actionId)
1135
+ },
1136
+ cursor: await this.deps.captureDeliveryCursor(run.rootSessionId),
1137
+ expectedCancellationGeneration: run.cancellationGeneration,
1138
+ eventKey: `prepare:${actionId}`
1139
+ }).effect;
1140
+ }
1141
+ const grant = effectGrant(effect) ?? undefined;
1142
+ let current = effect;
1143
+ let changed = false;
1144
+ if (effect.state !== 'committed') {
1145
+ const result = await this.runDurableEffect({
1146
+ run,
1147
+ effect,
1148
+ job,
1149
+ ...(grant ? { capabilityGrant: grant } : {}),
1150
+ execute: (token, dispatch) => {
1151
+ const envelope = grant && token
1152
+ ? workflowPrivateEnvelope({
1153
+ workflowId: run.id,
1154
+ phaseCapability: token,
1155
+ cycle: grant.cycle,
1156
+ revision: grant.revision,
1157
+ allowedRoles: grant.allowedRoles
1158
+ })
1159
+ : '';
1160
+ return this.deps.returnBaton({
1161
+ ...this.effectCall(run, effect, dispatch, job),
1162
+ job,
1163
+ sessionId: run.rootSessionId,
1164
+ text: [outcome.baton, envelope, privateCorrelationBlock(run.id, effect.actionId)]
1165
+ .filter(Boolean)
1166
+ .join('\n\n')
1167
+ });
1168
+ },
1169
+ validate: assertDeliveryReceipt
1170
+ });
1171
+ current = result.effect;
1172
+ changed = result.changed;
1173
+ }
1174
+ if (current.state !== 'committed' || !isDeliveryReceipt(current.receipt))
1175
+ return changed;
1176
+ const latestJob = this.db.getWorkflowJob(job.id);
1177
+ if (latestJob?.state !== 'returning')
1178
+ return true;
1179
+ const resolution = await this.deps.resolveDeliveryReceipt({
1180
+ ...this.effectReadCall(run, current, job),
1181
+ sessionId: run.rootSessionId,
1182
+ receipt: current.receipt
1183
+ });
1184
+ if (resolution.status === 'lost') {
1185
+ this.markReceiptLost(run, current, resolution.evidence);
1186
+ return true;
1187
+ }
1188
+ const resolved = messageReceipt(resolution, current.receipt.id);
1189
+ if (!resolved) {
1190
+ if (!isDeliveryReceipt(latestJob.batonReceipt)) {
1191
+ this.db.updateWorkflowJob({
1192
+ jobId: job.id,
1193
+ expectedStates: ['returning'],
1194
+ expectedCancellationGeneration: run.cancellationGeneration,
1195
+ state: 'returning',
1196
+ batonReceipt: current.receipt,
1197
+ eventKey: `baton-accepted:${job.id}:${current.receipt.id}`,
1198
+ eventType: 'workflow_baton_accepted'
1199
+ });
1200
+ return true;
1201
+ }
1202
+ return changed;
1203
+ }
1204
+ this.db.idempotentMutation('workflow_baton_delivered', `${job.id}:${resolved.id}`, resolved, () => {
1205
+ this.db.updateWorkflowJobAttempt({
1206
+ jobId: job.id,
1207
+ attemptNumber: job.attemptCount,
1208
+ expectedState: 'returning',
1209
+ state: 'returned',
1210
+ outcome: outcome,
1211
+ eventKey: `attempt-returned:${job.id}:${job.attemptCount}`,
1212
+ eventType: 'workflow_job_attempt_returned'
1213
+ });
1214
+ this.db.updateWorkflowJob({
1215
+ jobId: job.id,
1216
+ expectedStates: ['returning'],
1217
+ expectedCancellationGeneration: run.cancellationGeneration,
1218
+ state: 'returned',
1219
+ batonReceipt: resolved,
1220
+ clearOwner: true,
1221
+ eventKey: `job-returned:${job.id}:${resolved.id}`,
1222
+ eventType: 'workflow_job_returned'
1223
+ });
1224
+ return { runId: run.id, jobId: job.id };
1225
+ }, { runId: run.id, actionId });
1226
+ this.advanceDeliveredBarrier(this.requireRun(run.id));
1227
+ return true;
1228
+ }
1229
+ advanceDeliveredBarrier(run) {
1230
+ if (run.phase !== 'exploring' && run.phase !== 'implementing')
1231
+ return false;
1232
+ const role = run.phase === 'exploring' ? 'exploration' : 'implementation';
1233
+ const jobs = this.db.listWorkflowJobs(run.id);
1234
+ const next = phaseAfterDeliveredBaton(role, guardJobs(jobs), run.cycle);
1235
+ if (!next)
1236
+ return false;
1237
+ const grantJob = [...jobs].reverse().find(job => {
1238
+ if (job.role !== role || job.cycle !== run.cycle || job.state !== 'returned')
1239
+ return false;
1240
+ const effect = this.db.getWorkflowEffect(run.id, `${job.id}:baton:${job.attemptCount}`);
1241
+ return effect ? effectGrant(effect)?.phase === next : false;
1242
+ });
1243
+ if (!grantJob) {
1244
+ this.blockRun(run, {
1245
+ actionId: `barrier:${role}:${run.cycle}`,
1246
+ errorCode: 'workflow_barrier_capability_missing',
1247
+ message: 'The final delivered Baton had no matching phase authorization.',
1248
+ retryClass: 'terminal'
1249
+ });
1250
+ return true;
1251
+ }
1252
+ const effect = this.requireEffect(run.id, `${grantJob.id}:baton:${grantJob.attemptCount}`);
1253
+ const receipt = isDeliveryReceipt(grantJob.batonReceipt) && grantJob.batonReceipt.kind === 'message'
1254
+ ? grantJob.batonReceipt
1255
+ : null;
1256
+ const grant = effectGrant(effect);
1257
+ if (!receipt || !grant || !effect.launchNonce)
1258
+ return false;
1259
+ this.db.idempotentMutation('advance_workflow_baton_barrier', `${run.id}:${role}:${run.cycle}:${receipt.id}`, { role, receipt, grant, tokenHash: effect.launchNonce }, () => {
1260
+ const current = this.requireRun(run.id);
1261
+ if (current.phase !== run.phase || current.cycle !== run.cycle || current.revision !== run.revision) {
1262
+ return { runId: run.id };
1263
+ }
1264
+ const implementationDelivered = this.db
1265
+ .listWorkflowJobs(run.id)
1266
+ .filter(job => job.role === 'implementation' &&
1267
+ job.state === 'returned' &&
1268
+ isDeliveryReceipt(job.batonReceipt) &&
1269
+ job.batonReceipt.kind === 'message').length;
1270
+ const advanced = this.db.transitionWorkflowRun({
1271
+ runId: run.id,
1272
+ expectedPhase: run.phase,
1273
+ expectedCancellationGeneration: current.cancellationGeneration,
1274
+ phase: next,
1275
+ cycle: grant.cycle,
1276
+ revision: grant.revision,
1277
+ implementationBatonsDelivered: implementationDelivered,
1278
+ eventKey: `barrier-advanced:${role}:${receipt.id}`,
1279
+ eventType: 'workflow_baton_barrier_satisfied',
1280
+ eventData: { role, receipt }
1281
+ });
1282
+ this.db.revokeWorkflowCapabilities(run.id, `revoke-after-barrier:${receipt.id}`, run.phase);
1283
+ this.db.issueWorkflowCapability({
1284
+ tokenHash: effect.launchNonce,
1285
+ runId: run.id,
1286
+ rootSessionId: advanced.rootSessionId,
1287
+ cycle: advanced.cycle,
1288
+ phase: next,
1289
+ revision: advanced.revision,
1290
+ allowedRoles: grant.allowedRoles,
1291
+ issuedWith: { rowid: receipt.rowid, ...(receipt.turnId ? { turnId: receipt.turnId } : {}) },
1292
+ eventKey: `capability-after-baton:${receipt.id}`
1293
+ });
1294
+ return { runId: run.id };
1295
+ }, { runId: run.id, actionId: effect.actionId });
1296
+ return true;
1297
+ }
1298
+ async delegate(input) {
1299
+ const tokenHash = hashCapabilityToken(input.phaseCapability);
1300
+ const request = {
1301
+ workflowId: input.workflowId,
1302
+ sessionId: input.sessionId,
1303
+ tokenHash,
1304
+ role: input.role,
1305
+ task: input.task,
1306
+ planningInterpretation: input.planningInterpretation
1307
+ };
1308
+ const replay = this.db.getIdempotentMutation('workflow_delegate', input.clientId, request);
1309
+ if (replay) {
1310
+ const job = this.db.getWorkflowJob(replay.result.jobId);
1311
+ if (!job)
1312
+ throw new WorkflowTransitionError('accepted Workflow job disappeared');
1313
+ return { replayed: true, job, workflow: this.projection(replay.result.runId) };
1314
+ }
1315
+ this.heartbeat();
1316
+ if (!input.task.trim())
1317
+ throw new WorkflowCoordinatorError('invalid_request', 'Delegation needs a focused task.', { status: 400 });
1318
+ const initial = this.db.getWorkflowRun(input.workflowId);
1319
+ if (!initial)
1320
+ throw new WorkflowCoordinatorError('workflow_not_found', 'Workflow does not exist.', { status: 404 });
1321
+ const jobId = randomUUID();
1322
+ const transcriptCursor = await this.deps.captureTranscriptCursor?.(input.sessionId);
1323
+ const deliveryCursor = await this.deps.captureDeliveryCursor(input.sessionId);
1324
+ const role = initial.roles[input.role];
1325
+ const prompt = [
1326
+ workflowChildPrompt({
1327
+ roleName: input.role,
1328
+ objective: initial.objective,
1329
+ role: promptRole(role),
1330
+ task: input.task
1331
+ }),
1332
+ privateCorrelationBlock(initial.id, `job:${jobId}`)
1333
+ ].join('\n\n');
1334
+ const mutation = this.db.idempotentMutation('workflow_delegate', input.clientId, request, () => {
1335
+ const run = this.requireRun(input.workflowId);
1336
+ const jobs = this.db.listWorkflowJobs(run.id);
1337
+ const capability = this.db.getWorkflowCapability(tokenHash);
1338
+ const claims = capability &&
1339
+ (capability.phase === 'exploring' || capability.phase === 'planning' || capability.phase === 'reviewing')
1340
+ ? {
1341
+ runId: capability.runId,
1342
+ rootSessionId: capability.rootSessionId,
1343
+ cycle: capability.cycle,
1344
+ revision: capability.revision,
1345
+ phase: capability.phase,
1346
+ allowedRoles: capability.allowedRoles,
1347
+ consumed: capability.consumedAt !== undefined,
1348
+ revoked: capability.revokedAt !== undefined
1349
+ }
1350
+ : null;
1351
+ const guardRun = {
1352
+ id: run.id,
1353
+ rootSessionId: run.rootSessionId ?? null,
1354
+ phase: run.phase,
1355
+ cycle: run.cycle,
1356
+ revision: run.revision
1357
+ };
1358
+ assertWorkflowDelegation(guardRun, claims, { sessionId: input.sessionId, role: input.role }, guardJobs(jobs));
1359
+ const transition = workflowDelegationTransition(guardRun, input.role);
1360
+ const ordinal = actionOrdinal(jobs, input.role, transition.cycle);
1361
+ const logicalKey = `${transition.logicalPrefix}:${transition.cycle}:${ordinal}`;
1362
+ this.db.consumeWorkflowCapability({
1363
+ tokenHash,
1364
+ runId: run.id,
1365
+ rootSessionId: input.sessionId,
1366
+ role: input.role,
1367
+ expectedPhase: run.phase,
1368
+ expectedCycle: run.cycle,
1369
+ expectedRevision: run.revision,
1370
+ eventKey: `capability-consumed:${logicalKey}`
1371
+ });
1372
+ const created = this.db.createWorkflowJob({
1373
+ id: jobId,
1374
+ runId: run.id,
1375
+ logicalKey,
1376
+ role: input.role,
1377
+ cycle: transition.cycle,
1378
+ revision: transition.revision,
1379
+ resolvedRole: role,
1380
+ prompt,
1381
+ state: 'queued',
1382
+ transcriptCursor,
1383
+ expectedCancellationGeneration: run.cancellationGeneration,
1384
+ eventKey: `delegated:${logicalKey}`
1385
+ });
1386
+ const advanced = this.db.transitionWorkflowRun({
1387
+ runId: run.id,
1388
+ expectedPhase: run.phase,
1389
+ expectedCancellationGeneration: run.cancellationGeneration,
1390
+ phase: transition.phase,
1391
+ cycle: transition.cycle,
1392
+ revision: transition.revision,
1393
+ ...(input.role === 'implementation'
1394
+ ? { planningInterpretation: input.planningInterpretation ?? input.task }
1395
+ : {}),
1396
+ eventKey: `delegation-transition:${logicalKey}`,
1397
+ eventType: 'workflow_delegation_accepted',
1398
+ eventData: { jobId: created.job.id, logicalKey, role: input.role }
1399
+ });
1400
+ this.db.revokeWorkflowCapabilities(run.id, `revoke-after-delegation:${logicalKey}`, run.phase);
1401
+ if (input.role === 'exploration') {
1402
+ const actionId = `authorize:exploring:${advanced.cycle}:${advanced.revision}`;
1403
+ this.db.prepareWorkflowEffect({
1404
+ runId: run.id,
1405
+ actionId,
1406
+ kind: 'authorize_phase',
1407
+ target: { sessionId: input.sessionId },
1408
+ inputs: {
1409
+ grant: {
1410
+ phase: 'exploring',
1411
+ cycle: advanced.cycle,
1412
+ revision: advanced.revision,
1413
+ allowedRoles: ['exploration']
1414
+ },
1415
+ correlationMarker: workflowEffectCorrelationMarker(run.id, actionId)
1416
+ },
1417
+ cursor: deliveryCursor,
1418
+ expectedCancellationGeneration: run.cancellationGeneration,
1419
+ eventKey: `prepare:${actionId}`
1420
+ });
1421
+ }
1422
+ return { runId: run.id, jobId: created.job.id };
1423
+ }, { runId: input.workflowId });
1424
+ const job = this.db.getWorkflowJob(mutation.result.jobId);
1425
+ if (!job)
1426
+ throw new WorkflowTransitionError('accepted Workflow job disappeared');
1427
+ return { replayed: mutation.replayed, job, workflow: this.projection(input.workflowId) };
1428
+ }
1429
+ async retry(input) {
1430
+ const replay = this.db.getIdempotentMutation('workflow_retry', input.clientId, input);
1431
+ if (replay)
1432
+ return { replayed: true, workflow: this.projection(replay.result.runId) };
1433
+ this.heartbeat();
1434
+ await this.assertCompatible();
1435
+ const mutation = this.db.idempotentMutation('workflow_retry', input.clientId, input, () => {
1436
+ const run = this.requireBlockedRun(input.workflowId);
1437
+ const actionId = run.blocked?.actionId;
1438
+ if (run.blocked?.retryClass !== 'deterministic') {
1439
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'This blocked action is not safely retryable.');
1440
+ }
1441
+ if (actionId.startsWith('job:')) {
1442
+ const jobId = actionId.slice(4);
1443
+ this.db.updateWorkflowJob({
1444
+ jobId,
1445
+ expectedStates: ['failed'],
1446
+ expectedCancellationGeneration: run.cancellationGeneration,
1447
+ state: 'queued',
1448
+ childSessionId: null,
1449
+ outcome: null,
1450
+ taskReceipt: null,
1451
+ batonReceipt: null,
1452
+ clearOwner: true,
1453
+ eventKey: `job-retry:${jobId}:${input.clientId}`,
1454
+ eventType: 'workflow_job_retry_queued'
1455
+ });
1456
+ }
1457
+ else if (actionId.startsWith('compatibility:') || actionId === 'bind-root') {
1458
+ // No external intent exists for these read-only gates; unblocking is the retry.
1459
+ }
1460
+ else {
1461
+ this.db.retryWorkflowEffect(run.id, actionId, `effect-retry:${actionId}:${input.clientId}`);
1462
+ }
1463
+ this.db.transitionWorkflowRun({
1464
+ runId: run.id,
1465
+ expectedPhase: 'blocked',
1466
+ expectedCancellationGeneration: run.cancellationGeneration,
1467
+ phase: run.blocked?.resumePhase,
1468
+ blocked: null,
1469
+ eventKey: `workflow-retry:${actionId}:${input.clientId}`,
1470
+ eventType: 'workflow_retry_accepted'
1471
+ });
1472
+ return { runId: run.id };
1473
+ }, { runId: input.workflowId });
1474
+ return { replayed: mutation.replayed, workflow: this.projection(input.workflowId) };
1475
+ }
1476
+ async adopt(input) {
1477
+ const replay = this.db.getIdempotentMutation('workflow_adopt', input.clientId, input);
1478
+ if (replay)
1479
+ return { replayed: true, workflow: this.projection(replay.result.runId) };
1480
+ this.heartbeat();
1481
+ const run = this.requireBlockedAction(input.workflowId, input.actionId);
1482
+ const candidate = run.blocked?.candidates?.find(item => item.id === input.candidateId);
1483
+ if (!candidate)
1484
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'That saved adoption candidate is unavailable.');
1485
+ const effect = this.requireEffect(run.id, input.actionId);
1486
+ if (effect.state !== 'ambiguous' || !this.deps.validateAdoption) {
1487
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'This action cannot be adopted.');
1488
+ }
1489
+ const receipt = await this.deps.validateAdoption({ run, effect, candidate });
1490
+ if (receipt === null)
1491
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The candidate no longer validates.');
1492
+ const mutation = this.db.idempotentMutation('workflow_adopt', input.clientId, input, () => {
1493
+ const current = this.requireBlockedAction(input.workflowId, input.actionId);
1494
+ const currentEffect = this.requireSameAmbiguousEffect(current.id, input.actionId, effect);
1495
+ this.db.markWorkflowEffectCommitted({
1496
+ runId: current.id,
1497
+ actionId: input.actionId,
1498
+ receipt: cleanUnknown(receipt),
1499
+ eventKey: `effect-adopted:${input.actionId}:${input.clientId}`
1500
+ });
1501
+ this.db.transitionWorkflowRun({
1502
+ runId: current.id,
1503
+ expectedPhase: 'blocked',
1504
+ expectedCancellationGeneration: current.cancellationGeneration,
1505
+ phase: current.blocked?.resumePhase,
1506
+ blocked: null,
1507
+ eventKey: `workflow-adopted:${input.actionId}:${input.clientId}`,
1508
+ eventType: 'workflow_effect_adopted',
1509
+ eventData: { candidateId: candidate.id }
1510
+ });
1511
+ this.clearMatchingQuarantine(currentEffect, `adopt:${input.clientId}`);
1512
+ return { runId: current.id };
1513
+ }, { runId: input.workflowId, actionId: input.actionId });
1514
+ return { replayed: mutation.replayed, workflow: this.projection(input.workflowId) };
1515
+ }
1516
+ async replay(input) {
1517
+ const prior = this.db.getIdempotentMutation('workflow_replay_ambiguous', input.clientId, input);
1518
+ if (prior)
1519
+ return { replayed: true, workflow: this.projection(prior.result.runId) };
1520
+ this.heartbeat();
1521
+ if (input.confirmDuplicateRisk !== true) {
1522
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Risky replay requires explicit duplicate-risk confirmation.');
1523
+ }
1524
+ const blocked = this.requireBlockedAction(input.workflowId, input.actionId);
1525
+ const effect = this.requireEffect(blocked.id, input.actionId);
1526
+ await this.reconcileBlockedEffect(blocked);
1527
+ if (this.requireRun(input.workflowId).phase !== 'blocked') {
1528
+ const reconciled = this.db.idempotentMutation('workflow_replay_ambiguous', input.clientId, input, () => ({ runId: input.workflowId }), { runId: input.workflowId, actionId: input.actionId });
1529
+ return { replayed: reconciled.replayed, workflow: this.projection(input.workflowId) };
1530
+ }
1531
+ const mutation = this.db.idempotentMutation('workflow_replay_ambiguous', input.clientId, input, () => {
1532
+ const run = this.requireBlockedAction(input.workflowId, input.actionId);
1533
+ if (run.blocked?.retryClass !== 'ambiguous') {
1534
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'This action is not an ambiguous effect.');
1535
+ }
1536
+ if (run.blocked.candidates?.length) {
1537
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Validate and adopt a saved candidate instead of risking a duplicate replay.');
1538
+ }
1539
+ const currentEffect = this.requireSameAmbiguousEffect(run.id, input.actionId, effect);
1540
+ this.db.replayAmbiguousWorkflowEffect(run.id, input.actionId, `effect-replay:${input.actionId}:${input.clientId}`);
1541
+ this.db.transitionWorkflowRun({
1542
+ runId: run.id,
1543
+ expectedPhase: 'blocked',
1544
+ expectedCancellationGeneration: run.cancellationGeneration,
1545
+ phase: run.blocked?.resumePhase,
1546
+ blocked: null,
1547
+ eventKey: `workflow-replay:${input.actionId}:${input.clientId}`,
1548
+ eventType: 'workflow_ambiguous_replay_confirmed'
1549
+ });
1550
+ this.clearMatchingQuarantine(currentEffect, `replay:${input.clientId}`);
1551
+ return { runId: run.id };
1552
+ }, { runId: input.workflowId, actionId: input.actionId });
1553
+ return { replayed: mutation.replayed, workflow: this.projection(input.workflowId) };
1554
+ }
1555
+ /** A later positive receipt resolves ambiguity safely; a newly visible candidate only refreshes the phone choice. */
1556
+ async reconcileBlockedEffect(run) {
1557
+ if (run.phase !== 'blocked' || run.blocked?.retryClass !== 'ambiguous')
1558
+ return false;
1559
+ const effect = this.db.getWorkflowEffect(run.id, run.blocked.actionId);
1560
+ if (effect?.state !== 'ambiguous')
1561
+ return false;
1562
+ const reconciliation = await this.deps.reconcileEffect?.(this.effectReadCall(run, effect));
1563
+ if (!reconciliation || reconciliation.status === 'pending')
1564
+ return false;
1565
+ if (reconciliation.status === 'committed') {
1566
+ const receipt = cleanUnknown(reconciliation.receipt);
1567
+ this.db.idempotentMutation('reconcile_blocked_workflow_effect', `${run.id}:${effect.actionId}:${receiptEventSuffix(receipt, `attempt:${effect.attemptCount}`)}`, { actionId: effect.actionId, receipt }, () => {
1568
+ const current = this.requireBlockedAction(run.id, effect.actionId);
1569
+ const currentEffect = this.requireSameAmbiguousEffect(current.id, effect.actionId, effect);
1570
+ this.db.markWorkflowEffectCommitted({
1571
+ runId: current.id,
1572
+ actionId: effect.actionId,
1573
+ receipt,
1574
+ eventKey: `effect-positive-while-blocked:${effect.actionId}:${effect.attemptCount}`
1575
+ });
1576
+ this.db.transitionWorkflowRun({
1577
+ runId: current.id,
1578
+ expectedPhase: 'blocked',
1579
+ expectedCancellationGeneration: current.cancellationGeneration,
1580
+ phase: current.blocked?.resumePhase,
1581
+ blocked: null,
1582
+ eventKey: `workflow-positive-while-blocked:${effect.actionId}:${effect.attemptCount}`,
1583
+ eventType: 'workflow_ambiguous_effect_reconciled'
1584
+ });
1585
+ this.clearMatchingQuarantine(currentEffect, `positive-receipt:${effect.id}`);
1586
+ return { runId: current.id };
1587
+ }, { runId: run.id, actionId: effect.actionId });
1588
+ return true;
1589
+ }
1590
+ const candidates = (reconciliation.candidates ?? [])
1591
+ .slice(0, 20)
1592
+ .map(candidate => cleanUnknown(candidate));
1593
+ const prior = run.blocked.candidates ?? [];
1594
+ if (JSON.stringify(prior) === JSON.stringify(candidates))
1595
+ return false;
1596
+ this.db.transitionWorkflowRun({
1597
+ runId: run.id,
1598
+ expectedPhase: 'blocked',
1599
+ expectedCancellationGeneration: run.cancellationGeneration,
1600
+ phase: 'blocked',
1601
+ blocked: { ...run.blocked, candidates },
1602
+ eventKey: `workflow-candidates-refreshed:${effect.actionId}:${effect.attemptCount}:${candidates.map(item => item.id).join(',') || 'none'}`,
1603
+ eventType: 'workflow_adoption_candidates_refreshed',
1604
+ eventData: { actionId: effect.actionId, candidateCount: candidates.length }
1605
+ });
1606
+ return true;
1607
+ }
1608
+ async complete(input) {
1609
+ const replay = this.db.getIdempotentMutation('workflow_complete', input.clientId, input);
1610
+ if (replay)
1611
+ return { replayed: true, workflow: this.projection(replay.result.runId) };
1612
+ this.heartbeat();
1613
+ const mutation = this.db.idempotentMutation('workflow_complete', input.clientId, input, () => {
1614
+ const run = this.requireRun(input.workflowId);
1615
+ const jobs = this.db.listWorkflowJobs(run.id);
1616
+ const outstanding = jobs.some(job => !isTerminalWorkflowJobState(job.state));
1617
+ const implementationDelivered = jobs.some(job => job.role === 'implementation' &&
1618
+ job.state === 'returned' &&
1619
+ isDeliveryReceipt(job.batonReceipt) &&
1620
+ job.batonReceipt.kind === 'message');
1621
+ if (run.phase !== 'reviewing' ||
1622
+ outstanding ||
1623
+ !implementationDelivered ||
1624
+ run.implementationBatonsDelivered < 1) {
1625
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Workflow can complete only from reviewing after a delivered implementation Baton and no outstanding job.');
1626
+ }
1627
+ this.db.transitionWorkflowRun({
1628
+ runId: run.id,
1629
+ expectedPhase: 'reviewing',
1630
+ expectedCancellationGeneration: run.cancellationGeneration,
1631
+ phase: 'completed',
1632
+ eventKey: `workflow-completed:${input.clientId}`,
1633
+ eventType: 'workflow_completed'
1634
+ });
1635
+ this.db.revokeWorkflowCapabilities(run.id, `revoke-on-complete:${input.clientId}`);
1636
+ return { runId: run.id };
1637
+ }, { runId: input.workflowId });
1638
+ return { replayed: mutation.replayed, workflow: this.projection(input.workflowId) };
1639
+ }
1640
+ async cancel(input) {
1641
+ const replay = this.db.getIdempotentMutation('workflow_cancel', input.clientId, input);
1642
+ if (replay)
1643
+ return { replayed: true, workflow: this.projection(replay.result.runId) };
1644
+ this.heartbeat();
1645
+ const mutation = this.db.idempotentMutation('workflow_cancel', input.clientId, input, () => {
1646
+ this.db.cancelWorkflowRun(input.workflowId, `workflow-cancelled:${input.clientId}`);
1647
+ this.db.revokeWorkflowCapabilities(input.workflowId, `revoke-on-cancel:${input.clientId}`);
1648
+ return { runId: input.workflowId };
1649
+ }, { runId: input.workflowId });
1650
+ return { replayed: mutation.replayed, workflow: this.projection(input.workflowId) };
1651
+ }
1652
+ projection(runId) {
1653
+ const projection = this.db.getWorkflowProjection(runId);
1654
+ if (!projection)
1655
+ throw new WorkflowCoordinatorError('workflow_not_found', 'Workflow does not exist.', { status: 404 });
1656
+ return projection;
1657
+ }
1658
+ projections(includeTerminal = false) {
1659
+ return this.db.listWorkflowProjections({ includeTerminal });
1660
+ }
1661
+ runIdsNeedingWake() {
1662
+ return this.db.listWorkflowRunIdsNeedingWake();
1663
+ }
1664
+ workflowForSession(sessionId) {
1665
+ for (const projection of this.db.listWorkflowProjections()) {
1666
+ if (projection.rootSessionId === sessionId)
1667
+ return projection;
1668
+ if (this.db.listWorkflowJobs(projection.id).some(job => job.childSessionId === sessionId))
1669
+ return projection;
1670
+ }
1671
+ return undefined;
1672
+ }
1673
+ ownsSession(sessionId) {
1674
+ return this.workflowForSession(sessionId) !== undefined;
1675
+ }
1676
+ async runDurableEffect(options) {
1677
+ let effect = this.requireEffect(options.run.id, options.effect.actionId);
1678
+ if (effect.state === 'committed' ||
1679
+ effect.state === 'failed' ||
1680
+ effect.state === 'ambiguous' ||
1681
+ effect.state === 'cancelled') {
1682
+ return { effect, changed: false };
1683
+ }
1684
+ if (!effect.owner &&
1685
+ effect.state === 'prepared' &&
1686
+ (effect.kind === 'configure_root' || effect.kind === 'configure_child')) {
1687
+ const satisfaction = await this.deps.reconcileEffect?.(this.effectReadCall(options.run, effect, options.job));
1688
+ if (satisfaction?.status === 'committed') {
1689
+ const latest = this.requireEffect(options.run.id, effect.actionId);
1690
+ const latestRun = this.requireRun(options.run.id);
1691
+ if (!latest.owner &&
1692
+ latest.state === 'prepared' &&
1693
+ latestRun.phase !== 'blocked' &&
1694
+ !isTerminalWorkflowPhase(latestRun.phase)) {
1695
+ effect = this.db.markWorkflowEffectSatisfiedWithoutDispatch({
1696
+ runId: latestRun.id,
1697
+ actionId: latest.actionId,
1698
+ expectedCancellationGeneration: latestRun.cancellationGeneration,
1699
+ receipt: cleanUnknown(satisfaction.receipt),
1700
+ eventKey: `effect-satisfied:${latest.actionId}`
1701
+ });
1702
+ return { effect, changed: true };
1703
+ }
1704
+ }
1705
+ }
1706
+ if (effect.owner) {
1707
+ const reconciled = await this.reconcileClaimedEffect(options.run, effect, options.job);
1708
+ effect = reconciled.effect;
1709
+ if (reconciled.changed || effect.owner || effect.state !== 'prepared')
1710
+ return reconciled;
1711
+ }
1712
+ const currentRun = this.requireRun(options.run.id);
1713
+ if (currentRun.phase === 'blocked' || isTerminalWorkflowPhase(currentRun.phase))
1714
+ return { effect, changed: false };
1715
+ const claim = this.db.claimPreparedWorkflowEffect({
1716
+ runId: options.run.id,
1717
+ actionId: effect.actionId,
1718
+ owner: this.relay,
1719
+ expectedCancellationGeneration: currentRun.cancellationGeneration
1720
+ });
1721
+ if (!claim)
1722
+ return { effect: this.requireEffect(options.run.id, effect.actionId), changed: false };
1723
+ const capabilityToken = options.capabilityGrant
1724
+ ? workflowCapabilityToken(randomBytes(32).toString('base64url'))
1725
+ : undefined;
1726
+ const launchNonce = capabilityToken ? hashCapabilityToken(capabilityToken) : randomBytes(32).toString('hex');
1727
+ const dispatchMode = this.deps.dispatchMode?.(claim.effect) ?? 'in_process';
1728
+ const dispatch = {
1729
+ mode: dispatchMode,
1730
+ gatedProcessReady: async (externalProcess) => {
1731
+ if (dispatchMode !== 'gated_child') {
1732
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', `Workflow adapter for ${effect.actionId} registered a gated process in in-process mode.`);
1733
+ }
1734
+ const latestRun = this.requireRun(options.run.id);
1735
+ if (latestRun.cancellationGeneration !== currentRun.cancellationGeneration ||
1736
+ latestRun.phase === 'blocked' ||
1737
+ isTerminalWorkflowPhase(latestRun.phase)) {
1738
+ throw new WorkflowTransitionError(`Workflow ${latestRun.id} changed before gated process release`);
1739
+ }
1740
+ await this.assertCompatible();
1741
+ this.db.markWorkflowEffectMayExecute({
1742
+ runId: latestRun.id,
1743
+ actionId: effect.actionId,
1744
+ owner: this.relay,
1745
+ attemptNumber: claim.attempt.attemptNumber,
1746
+ launchNonce,
1747
+ externalProcess
1748
+ });
1749
+ }
1750
+ };
1751
+ try {
1752
+ return await withUiPriority('background', () => withUiDispatchHook(async () => {
1753
+ const latestRun = this.requireRun(options.run.id);
1754
+ if (latestRun.cancellationGeneration !== currentRun.cancellationGeneration ||
1755
+ latestRun.phase === 'blocked' ||
1756
+ isTerminalWorkflowPhase(latestRun.phase)) {
1757
+ throw new WorkflowTransitionError(`Workflow ${latestRun.id} changed before UI dispatch`);
1758
+ }
1759
+ await this.deps.validateBeforeDispatch?.(this.effectReadCall(latestRun, this.requireEffect(latestRun.id, effect.actionId), options.job));
1760
+ await this.assertCompatible();
1761
+ this.db.markWorkflowEffectDispatched({
1762
+ runId: latestRun.id,
1763
+ actionId: effect.actionId,
1764
+ owner: this.relay,
1765
+ attemptNumber: claim.attempt.attemptNumber,
1766
+ launchNonce,
1767
+ mayExecute: dispatchMode === 'in_process'
1768
+ });
1769
+ }, async () => {
1770
+ try {
1771
+ const result = await options.execute(capabilityToken, dispatch);
1772
+ effect = this.requireEffect(options.run.id, effect.actionId);
1773
+ if (effect.state !== 'dispatched') {
1774
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', `Workflow adapter for ${effect.actionId} returned without entering uiTurn.`);
1775
+ }
1776
+ if (!effect.mayExecute) {
1777
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', `Workflow adapter for ${effect.actionId} returned before releasing its gated process.`);
1778
+ }
1779
+ const receipt = cleanUnknown(options.validate(result));
1780
+ const completedRun = this.requireRun(options.run.id);
1781
+ if (completedRun.phase === 'cancelled') {
1782
+ effect = this.db.recordLateWorkflowEffect({
1783
+ runId: completedRun.id,
1784
+ actionId: effect.actionId,
1785
+ receipt,
1786
+ eventKey: `late-effect:${effect.actionId}:${claim.attempt.attemptNumber}`
1787
+ });
1788
+ return { effect, changed: true };
1789
+ }
1790
+ if (completedRun.phase === 'completed')
1791
+ return { effect, changed: false };
1792
+ effect = this.db.markWorkflowEffectCommitted({
1793
+ runId: options.run.id,
1794
+ actionId: effect.actionId,
1795
+ owner: this.relay,
1796
+ attemptNumber: claim.attempt.attemptNumber,
1797
+ receipt
1798
+ });
1799
+ if (this.requireRun(options.run.id).phase === 'cancelled') {
1800
+ effect = this.db.recordLateWorkflowEffect({
1801
+ runId: options.run.id,
1802
+ actionId: effect.actionId,
1803
+ receipt,
1804
+ eventKey: `late-effect:${effect.actionId}:${claim.attempt.attemptNumber}`
1805
+ });
1806
+ }
1807
+ return { effect, changed: true };
1808
+ }
1809
+ catch (error) {
1810
+ return this.failEffect(options.run, effect.actionId, claim.attempt.attemptNumber, error, options.job);
1811
+ }
1812
+ }, claim.effect.id));
1813
+ }
1814
+ catch (error) {
1815
+ return this.failEffect(options.run, effect.actionId, claim.attempt.attemptNumber, error, options.job);
1816
+ }
1817
+ }
1818
+ async reconcileClaimedEffect(run, effect, job) {
1819
+ const positive = await this.deps.reconcileEffect?.(this.effectReadCall(run, effect, job));
1820
+ if (positive?.status === 'committed') {
1821
+ const committed = this.db.markWorkflowEffectCommitted({
1822
+ runId: run.id,
1823
+ actionId: effect.actionId,
1824
+ receipt: cleanUnknown(positive.receipt),
1825
+ eventKey: `effect-reconciled:${effect.actionId}:${effect.attemptCount}`
1826
+ });
1827
+ return { effect: committed, changed: true };
1828
+ }
1829
+ const recovery = this.db.reconcileAbandonedWorkflowEffect({
1830
+ runId: run.id,
1831
+ actionId: effect.actionId,
1832
+ eventKey: `recover-effect:${effect.actionId}:${effect.attemptCount}`
1833
+ });
1834
+ if (recovery.status === 'safely_prepared')
1835
+ return { effect: recovery.effect, changed: true };
1836
+ if (recovery.status === 'ambiguous') {
1837
+ this.quarantineAndBlock(run, recovery.effect, positive?.status === 'ambiguous' ? positive.candidates : undefined);
1838
+ return { effect: recovery.effect, changed: true };
1839
+ }
1840
+ return { effect: recovery.effect, changed: false };
1841
+ }
1842
+ async failEffect(run, actionId, attemptNumber, error, job) {
1843
+ let effect = this.requireEffect(run.id, actionId);
1844
+ const currentRun = this.requireRun(run.id);
1845
+ if (currentRun.phase === 'cancelled' || currentRun.phase === 'completed') {
1846
+ if (effect.state === 'dispatched') {
1847
+ if (!effect.mayExecute) {
1848
+ effect = this.db.markWorkflowEffectFailedBeforeMayExecute({
1849
+ runId: run.id,
1850
+ actionId,
1851
+ owner: this.relay,
1852
+ attemptNumber,
1853
+ errorCode: errorCode(error, 'workflow_effect_failed'),
1854
+ errorMessage: errorMessage(error),
1855
+ evidence: cleanUnknown(error)
1856
+ });
1857
+ return { effect, changed: true };
1858
+ }
1859
+ let positive;
1860
+ try {
1861
+ positive = await this.deps.reconcileEffect?.(this.effectReadCall(currentRun, effect, job));
1862
+ }
1863
+ catch {
1864
+ // Failure to read a receipt is not negative evidence. Preserve ambiguity below.
1865
+ }
1866
+ if (positive?.status === 'committed') {
1867
+ const receipt = cleanUnknown(positive.receipt);
1868
+ effect =
1869
+ currentRun.phase === 'cancelled'
1870
+ ? this.db.recordLateWorkflowEffect({
1871
+ runId: run.id,
1872
+ actionId,
1873
+ receipt,
1874
+ eventKey: `late-effect-error:${actionId}:${attemptNumber}`
1875
+ })
1876
+ : this.db.markWorkflowEffectCommitted({
1877
+ runId: run.id,
1878
+ actionId,
1879
+ owner: this.relay,
1880
+ attemptNumber,
1881
+ receipt
1882
+ });
1883
+ return { effect, changed: true };
1884
+ }
1885
+ effect = this.db.markWorkflowEffectAmbiguous({
1886
+ runId: run.id,
1887
+ actionId,
1888
+ owner: this.relay,
1889
+ attemptNumber,
1890
+ errorCode: errorCode(error, 'ambiguous_effect'),
1891
+ errorMessage: errorMessage(error),
1892
+ evidence: cleanUnknown(error)
1893
+ });
1894
+ this.db.activateUiQuarantine({
1895
+ actionId: effect.actionId,
1896
+ effectId: effect.id,
1897
+ reason: effect.errorMessage ?? 'A terminal Workflow has an unresolved UI effect.',
1898
+ owner: effect.owner,
1899
+ externalProcess: effect.externalProcess
1900
+ });
1901
+ return { effect, changed: true };
1902
+ }
1903
+ return { effect, changed: false };
1904
+ }
1905
+ if (effect.state === 'prepared' && sameRelay(effect.owner, this.relay)) {
1906
+ effect = this.db.markWorkflowEffectFailed({
1907
+ runId: run.id,
1908
+ actionId,
1909
+ owner: this.relay,
1910
+ attemptNumber,
1911
+ errorCode: errorCode(error, 'workflow_effect_failed'),
1912
+ errorMessage: errorMessage(error),
1913
+ evidence: cleanUnknown(error)
1914
+ });
1915
+ this.blockRun(currentRun, {
1916
+ actionId,
1917
+ errorCode: effect.errorCode ?? 'workflow_effect_failed',
1918
+ message: effect.errorMessage ?? 'Workflow UI action failed before dispatch.',
1919
+ retryClass: preExecutionRetryClass(error)
1920
+ });
1921
+ return { effect, changed: true };
1922
+ }
1923
+ if (effect.state === 'dispatched') {
1924
+ if (!effect.mayExecute) {
1925
+ effect = this.db.markWorkflowEffectFailedBeforeMayExecute({
1926
+ runId: run.id,
1927
+ actionId,
1928
+ owner: this.relay,
1929
+ attemptNumber,
1930
+ errorCode: errorCode(error, 'workflow_effect_failed'),
1931
+ errorMessage: errorMessage(error),
1932
+ evidence: cleanUnknown(error)
1933
+ });
1934
+ this.blockRun(currentRun, {
1935
+ actionId,
1936
+ errorCode: effect.errorCode ?? 'workflow_effect_failed',
1937
+ message: effect.errorMessage ?? 'Workflow external command failed before its private gate opened.',
1938
+ retryClass: preExecutionRetryClass(error)
1939
+ });
1940
+ return { effect, changed: true };
1941
+ }
1942
+ let positive;
1943
+ try {
1944
+ positive = await this.deps.reconcileEffect?.(this.effectReadCall(currentRun, effect, job));
1945
+ }
1946
+ catch {
1947
+ // A failed receipt read cannot make an already-authorized external action safe to replay.
1948
+ }
1949
+ if (positive?.status === 'committed') {
1950
+ effect = this.db.markWorkflowEffectCommitted({
1951
+ runId: run.id,
1952
+ actionId,
1953
+ owner: this.relay,
1954
+ attemptNumber,
1955
+ receipt: cleanUnknown(positive.receipt),
1956
+ eventKey: `effect-positive-after-error:${actionId}:${attemptNumber}`
1957
+ });
1958
+ return { effect, changed: true };
1959
+ }
1960
+ effect = this.db.markWorkflowEffectAmbiguous({
1961
+ runId: run.id,
1962
+ actionId,
1963
+ owner: this.relay,
1964
+ attemptNumber,
1965
+ errorCode: errorCode(error, 'ambiguous_effect'),
1966
+ errorMessage: errorMessage(error),
1967
+ evidence: cleanUnknown(error)
1968
+ });
1969
+ this.quarantineAndBlock(currentRun, effect, positive?.status === 'ambiguous' ? positive.candidates : undefined);
1970
+ return { effect, changed: true };
1971
+ }
1972
+ return { effect, changed: false };
1973
+ }
1974
+ markReceiptLost(run, effect, evidence) {
1975
+ if (!isDeliveryReceipt(effect.receipt) || effect.receipt.kind !== 'outbox') {
1976
+ throw new WorkflowCoordinatorError('workflow_adapter_invalid', `Delivery resolver reported a non-outbox receipt lost for ${effect.actionId}.`);
1977
+ }
1978
+ const errorMessage = 'Conductor accepted the queued message, but it disappeared before a durable message row was found.';
1979
+ const ambiguous = effect.state === 'dispatched'
1980
+ ? this.db.markWorkflowEffectAmbiguous({
1981
+ runId: run.id,
1982
+ actionId: effect.actionId,
1983
+ errorCode: 'outbox_receipt_lost',
1984
+ errorMessage,
1985
+ ...(evidence === undefined ? {} : { evidence: cleanUnknown(evidence) }),
1986
+ eventKey: `effect-receipt-lost:${effect.actionId}:${effect.receipt.id}`
1987
+ })
1988
+ : this.db.markWorkflowEffectReceiptLost({
1989
+ runId: run.id,
1990
+ actionId: effect.actionId,
1991
+ expectedReceipt: effect.receipt,
1992
+ errorCode: 'outbox_receipt_lost',
1993
+ errorMessage,
1994
+ ...(evidence === undefined ? {} : { evidence: cleanUnknown(evidence) }),
1995
+ eventKey: `effect-receipt-lost:${effect.actionId}:${effect.receipt.id}`
1996
+ });
1997
+ const current = this.requireRun(run.id);
1998
+ if (current.phase === 'cancelled') {
1999
+ this.db.activateUiQuarantine({
2000
+ actionId: ambiguous.actionId,
2001
+ effectId: ambiguous.id,
2002
+ reason: ambiguous.errorMessage ?? 'A cancelled Workflow has a lost accepted outbox receipt.',
2003
+ owner: ambiguous.owner,
2004
+ externalProcess: ambiguous.externalProcess
2005
+ });
2006
+ return;
2007
+ }
2008
+ this.quarantineAndBlock(current, ambiguous);
2009
+ }
2010
+ quarantineAndBlock(run, effect, candidates) {
2011
+ this.db.activateUiQuarantine({
2012
+ actionId: effect.actionId,
2013
+ effectId: effect.id,
2014
+ reason: effect.errorMessage ?? 'Workflow UI effect has no positive receipt.',
2015
+ owner: effect.owner,
2016
+ externalProcess: effect.externalProcess
2017
+ });
2018
+ this.blockRun(this.requireRun(run.id), {
2019
+ actionId: effect.actionId,
2020
+ errorCode: effect.errorCode ?? 'workflow_effect_ambiguous',
2021
+ message: effect.errorMessage ?? 'The UI action may have executed; automatic replay is disabled.',
2022
+ retryClass: 'ambiguous',
2023
+ candidates
2024
+ });
2025
+ }
2026
+ /** Call only inside the surrounding durable mutation so a newer hold cannot be cleared between read and write. */
2027
+ clearMatchingQuarantine(effect, clearedBy) {
2028
+ const quarantine = this.db.getUiQuarantine();
2029
+ const matches = quarantine.effectId
2030
+ ? quarantine.effectId === effect.id
2031
+ : quarantine.actionId === effect.id || quarantine.actionId === effect.actionId;
2032
+ if (quarantine.active && matches) {
2033
+ this.db.clearUiQuarantine(clearedBy);
2034
+ }
2035
+ }
2036
+ requireSameAmbiguousEffect(runId, actionId, expected) {
2037
+ const current = this.requireEffect(runId, actionId);
2038
+ if (current.state !== 'ambiguous' || current.attemptCount !== expected.attemptCount) {
2039
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The ambiguous action changed while it was being validated; inspect its current state and try again.');
2040
+ }
2041
+ return current;
2042
+ }
2043
+ blockRun(run, blocked) {
2044
+ if (run.phase === 'blocked' || isTerminalWorkflowPhase(run.phase))
2045
+ return;
2046
+ const blockOrdinal = this.db.listWorkflowEvents(run.id).filter(event => event.type === 'workflow_blocked').length;
2047
+ this.db.transitionWorkflowRun({
2048
+ runId: run.id,
2049
+ expectedPhase: run.phase,
2050
+ expectedCancellationGeneration: run.cancellationGeneration,
2051
+ phase: 'blocked',
2052
+ blocked: {
2053
+ actionId: blocked.actionId,
2054
+ errorCode: scrubWorkflowSecrets(blocked.errorCode),
2055
+ message: scrubWorkflowSecrets(blocked.message).slice(0, 500),
2056
+ resumePhase: activeResumePhase(run),
2057
+ retryClass: blocked.retryClass,
2058
+ ...(blocked.candidates
2059
+ ? {
2060
+ candidates: blocked.candidates
2061
+ .slice(0, 20)
2062
+ .map(candidate => cleanUnknown(candidate))
2063
+ }
2064
+ : {})
2065
+ },
2066
+ eventKey: `workflow-blocked:${blocked.actionId}:${blockOrdinal}`,
2067
+ eventType: 'workflow_blocked',
2068
+ eventData: { actionId: blocked.actionId, retryClass: blocked.retryClass }
2069
+ });
2070
+ }
2071
+ requireBlockedAction(runId, actionId) {
2072
+ const run = this.requireBlockedRun(runId);
2073
+ if (run.blocked?.actionId !== actionId) {
2074
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Recovery does not match the current blocked action.');
2075
+ }
2076
+ return run;
2077
+ }
2078
+ requireBlockedRun(runId) {
2079
+ const run = this.requireRun(runId);
2080
+ if (run.phase !== 'blocked' || !run.blocked) {
2081
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Workflow has no current blocked action.');
2082
+ }
2083
+ return run;
2084
+ }
2085
+ requireRun(runId) {
2086
+ const run = this.db.getWorkflowRun(runId);
2087
+ if (!run)
2088
+ throw new WorkflowCoordinatorError('workflow_not_found', 'Workflow does not exist.', { status: 404 });
2089
+ return run;
2090
+ }
2091
+ requireEffect(runId, actionId) {
2092
+ const effect = this.db.getWorkflowEffect(runId, actionId);
2093
+ if (!effect)
2094
+ throw new WorkflowTransitionError(`Workflow effect ${actionId} does not exist`);
2095
+ return effect;
2096
+ }
2097
+ effectCall(run, effect, dispatch, job) {
2098
+ return {
2099
+ ...this.effectReadCall(run, effect, job),
2100
+ dispatch
2101
+ };
2102
+ }
2103
+ effectReadCall(run, effect, job) {
2104
+ return {
2105
+ run,
2106
+ effect,
2107
+ ...(job ? { job } : {}),
2108
+ correlationMarker: workflowEffectCorrelationMarker(run.id, effect.actionId)
2109
+ };
2110
+ }
2111
+ async assertCompatible() {
2112
+ await this.deps.assertCompatibleRelays?.();
2113
+ }
2114
+ heartbeat() {
2115
+ if (!this.db.heartbeatRelayInstance(this.relay)) {
2116
+ this.db.registerRelayInstance({ ...this.relay, canDriveUi: true });
2117
+ }
2118
+ }
2119
+ }