@osolmaz/pi-workflows 0.16.5 → 0.16.6

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 (171) hide show
  1. package/README.md +5 -5
  2. package/dist/builtins/autoimplement.workflow.d.ts +5 -1
  3. package/dist/builtins/autoimplement.workflow.js +29 -10
  4. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  5. package/dist/client/client.js +3 -1
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/client/protocol.d.ts +1 -1
  8. package/dist/client/protocol.js +18 -1
  9. package/dist/client/protocol.js.map +1 -1
  10. package/dist/client/view.d.ts +7 -5
  11. package/dist/extension/index.d.ts +2 -1
  12. package/dist/extension/index.js +115 -165
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/extension/recorder.d.ts +1 -1
  15. package/dist/extension/recorder.js +2 -2
  16. package/dist/extension/recorder.js.map +1 -1
  17. package/dist/extension/step-message.d.ts +1 -1
  18. package/dist/extension/step-message.js +4 -4
  19. package/dist/extension/step-message.js.map +1 -1
  20. package/dist/extension/workflow-message-coordinator.d.ts +12 -1
  21. package/dist/extension/workflow-message-coordinator.js +65 -54
  22. package/dist/extension/workflow-message-coordinator.js.map +1 -1
  23. package/dist/resource-managers/index.d.ts +1 -2
  24. package/dist/resource-managers/index.js +1 -2
  25. package/dist/resource-managers/index.js.map +1 -1
  26. package/dist/resource-managers/sqlite.d.ts +2 -295
  27. package/dist/resource-managers/sqlite.js +71 -1528
  28. package/dist/resource-managers/sqlite.js.map +1 -1
  29. package/dist/resource-managers/types.d.ts +2 -7
  30. package/dist/server/resource-runner-entry.js +2 -6
  31. package/dist/server/resource-runner-entry.js.map +1 -1
  32. package/dist/server/rpc-bridge.js +1 -1
  33. package/dist/server/rpc-bridge.js.map +1 -1
  34. package/dist/server/rpc-executor.js +2 -4
  35. package/dist/server/rpc-executor.js.map +1 -1
  36. package/dist/server/server-entry.js +3 -0
  37. package/dist/server/server-entry.js.map +1 -1
  38. package/dist/server/server.d.ts +9 -8
  39. package/dist/server/server.js +364 -541
  40. package/dist/server/server.js.map +1 -1
  41. package/dist/server/state.d.ts +16 -29
  42. package/dist/server/state.js +105 -319
  43. package/dist/server/state.js.map +1 -1
  44. package/dist/server/view.d.ts +4 -5
  45. package/dist/server/view.js +51 -51
  46. package/dist/server/view.js.map +1 -1
  47. package/dist/server/workflow-runner-entry.d.ts +1 -1
  48. package/dist/server/workflow-runner-entry.js +1 -49
  49. package/dist/server/workflow-runner-entry.js.map +1 -1
  50. package/dist/server/workflow-runner-protocol.d.ts +2 -7
  51. package/dist/server/workflow-runner-protocol.js +5 -10
  52. package/dist/server/workflow-runner-protocol.js.map +1 -1
  53. package/dist/server/workflow-runner-store.d.ts +5 -6
  54. package/dist/server/workflow-runner-store.js +7 -14
  55. package/dist/server/workflow-runner-store.js.map +1 -1
  56. package/dist/state/attempt-time.d.ts +21 -0
  57. package/dist/state/attempt-time.js +95 -0
  58. package/dist/state/attempt-time.js.map +1 -0
  59. package/dist/state/json.d.ts +2 -1
  60. package/dist/state/json.js.map +1 -1
  61. package/dist/state/project-store.d.ts +39 -0
  62. package/dist/state/project-store.js +154 -0
  63. package/dist/state/project-store.js.map +1 -0
  64. package/dist/state/prune.js +0 -7
  65. package/dist/state/prune.js.map +1 -1
  66. package/dist/state/schema.js +23 -23
  67. package/dist/state/workflow-messages.d.ts +1 -1
  68. package/dist/state/workflow-messages.js +3 -1
  69. package/dist/state/workflow-messages.js.map +1 -1
  70. package/dist/viewer/backup.js +5 -0
  71. package/dist/viewer/backup.js.map +1 -1
  72. package/dist/workflows/diagnostics.d.ts +8 -0
  73. package/dist/workflows/diagnostics.js +61 -0
  74. package/dist/workflows/diagnostics.js.map +1 -0
  75. package/dist/workflows/engine.d.ts +2 -15
  76. package/dist/workflows/engine.js +125 -245
  77. package/dist/workflows/engine.js.map +1 -1
  78. package/dist/workflows/human-decision.d.ts +3 -7
  79. package/dist/workflows/human-decision.js +2 -56
  80. package/dist/workflows/human-decision.js.map +1 -1
  81. package/dist/workflows/index.d.ts +2 -1
  82. package/dist/workflows/index.js +1 -0
  83. package/dist/workflows/index.js.map +1 -1
  84. package/dist/workflows/queue.d.ts +267 -0
  85. package/dist/workflows/queue.js +1248 -0
  86. package/dist/workflows/queue.js.map +1 -0
  87. package/dist/workflows/requests.d.ts +47 -0
  88. package/dist/workflows/requests.js +158 -0
  89. package/dist/workflows/requests.js.map +1 -0
  90. package/dist/workflows/schema.js +2 -4
  91. package/dist/workflows/schema.js.map +1 -1
  92. package/dist/workflows/store.d.ts +11 -16
  93. package/dist/workflows/store.js +174 -169
  94. package/dist/workflows/store.js.map +1 -1
  95. package/dist/workflows/tool-input.d.ts +6 -9
  96. package/dist/workflows/tool-input.js +15 -12
  97. package/dist/workflows/tool-input.js.map +1 -1
  98. package/dist/workflows/transitions.d.ts +38 -0
  99. package/dist/workflows/transitions.js +276 -0
  100. package/dist/workflows/transitions.js.map +1 -0
  101. package/dist/workflows/types.d.ts +9 -42
  102. package/dist/workflows/workflow-message-content.d.ts +7 -1
  103. package/dist/workflows/workflow-message-content.js +22 -4
  104. package/dist/workflows/workflow-message-content.js.map +1 -1
  105. package/docs/2026-08-25-workflow-follow-ups.md +5 -5
  106. package/docs/2026-09-02-unify-workflow-messages-plan.md +2 -0
  107. package/docs/2026-09-04-workflow-run-state-plan.md +2 -0
  108. package/docs/2026-09-06-durable-execution-plan.md +176 -0
  109. package/docs/DEFERRED_TURNS.md +84 -105
  110. package/docs/HUMAN_DECISIONS.md +17 -19
  111. package/docs/HUMAN_DECISION_PRESENTATIONS.md +1 -1
  112. package/docs/SQLITE_STATE.md +68 -29
  113. package/docs/WORKFLOW_SERVER.md +21 -10
  114. package/docs/WORKFLOW_STEP_MESSAGES.md +72 -54
  115. package/docs/plans/2026-08-04-controller-runtime-plan.md +2 -0
  116. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +2 -0
  117. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +2 -0
  118. package/docs/workflows.md +94 -45
  119. package/examples/workflows/autoresearch.workflow.ts +0 -2
  120. package/examples/workflows/branch.workflow.ts +0 -4
  121. package/examples/workflows/echo.workflow.ts +0 -2
  122. package/examples/workflows/live-settings.workflow.ts +0 -2
  123. package/examples/workflows/two-turn.workflow.ts +0 -2
  124. package/herdr-plugin.toml +1 -1
  125. package/package.json +1 -1
  126. package/protocol/fixtures/client-v1.json +1 -1
  127. package/skills/pi-workflows/SKILL.md +8 -7
  128. package/src/builtins/autoimplement.workflow.ts +31 -10
  129. package/src/client/client.ts +3 -1
  130. package/src/client/protocol.ts +21 -1
  131. package/src/client/view.ts +9 -5
  132. package/src/extension/index.ts +117 -189
  133. package/src/extension/recorder.ts +2 -2
  134. package/src/extension/step-message.ts +4 -4
  135. package/src/extension/workflow-message-coordinator.ts +78 -50
  136. package/src/resource-managers/index.ts +1 -9
  137. package/src/resource-managers/sqlite.ts +330 -2655
  138. package/src/resource-managers/types.ts +2 -3
  139. package/src/server/resource-runner-entry.ts +2 -7
  140. package/src/server/rpc-bridge.ts +1 -1
  141. package/src/server/rpc-executor.ts +4 -9
  142. package/src/server/server-entry.ts +3 -0
  143. package/src/server/server.ts +416 -641
  144. package/src/server/state.ts +122 -466
  145. package/src/server/view.ts +56 -60
  146. package/src/server/workflow-runner-entry.ts +2 -55
  147. package/src/server/workflow-runner-protocol.ts +5 -15
  148. package/src/server/workflow-runner-store.ts +12 -27
  149. package/src/state/attempt-time.ts +122 -0
  150. package/src/state/json.ts +2 -1
  151. package/src/state/project-store.ts +218 -0
  152. package/src/state/prune.ts +0 -13
  153. package/src/state/schema.ts +23 -23
  154. package/src/state/workflow-messages.ts +4 -2
  155. package/src/viewer/backup.ts +7 -0
  156. package/src/workflows/diagnostics.ts +78 -0
  157. package/src/workflows/engine.ts +133 -291
  158. package/src/workflows/human-decision.ts +3 -81
  159. package/src/workflows/index.ts +8 -2
  160. package/src/workflows/queue.ts +2091 -0
  161. package/src/workflows/requests.ts +283 -0
  162. package/src/workflows/schema.ts +2 -6
  163. package/src/workflows/store.ts +223 -231
  164. package/src/workflows/tool-input.ts +20 -12
  165. package/src/workflows/transitions.ts +330 -0
  166. package/src/workflows/types.ts +9 -46
  167. package/src/workflows/workflow-message-content.ts +28 -4
  168. package/dist/resource-managers/runtime.d.ts +0 -59
  169. package/dist/resource-managers/runtime.js +0 -423
  170. package/dist/resource-managers/runtime.js.map +0 -1
  171. package/src/resource-managers/runtime.ts +0 -576
@@ -5,6 +5,7 @@ import {
5
5
  type ClientRequest,
6
6
  type ClientResponse,
7
7
  } from "../client/protocol.js";
8
+ import { AttemptTime, type AttemptClock } from "../state/attempt-time.js";
8
9
  import { StateDatabase } from "../state/database.js";
9
10
  import { canonicalJson, type JsonValue } from "../state/json.js";
10
11
  import { tokenHash } from "../state/mutation.js";
@@ -13,8 +14,15 @@ import {
13
14
  workflowMessageIdFor,
14
15
  type WorkflowStepReason,
15
16
  } from "../state/workflow-messages.js";
17
+ import {
18
+ ensureWorkflowRequest,
19
+ readWorkflowRequest,
20
+ recordWorkflowSubmission,
21
+ type InteractiveRequestRecord,
22
+ } from "../workflows/requests.js";
16
23
  import {
17
24
  decisionWorkflowMessageContent,
25
+ checkpointWorkflowMessageContent,
18
26
  stepWorkflowMessageContent,
19
27
  } from "../workflows/workflow-message-content.js";
20
28
  import type { WorkflowRunnerMessage, WorkflowRunnerResponse } from "./workflow-runner-protocol.js";
@@ -69,29 +77,16 @@ export type InteractiveSubmissionRecord = {
69
77
  submittedAt: string;
70
78
  };
71
79
 
72
- export type InteractiveRequestRecord = {
73
- requestId: string;
74
- runId: string;
75
- attemptId: string;
76
- targetSessionId: string;
77
- kind: "agent" | "assistant" | "decision";
78
- contract: JsonValue;
79
- revision: number;
80
- status: "pending" | "settled" | "cancelled";
81
- unproductiveTurnEnds: number;
82
- acceptedSubmissionId: string | null;
83
- createdAt: string;
84
- updatedAt: string;
85
- settledAt: string | null;
86
- consumedAt: string | null;
87
- };
88
-
89
80
  export class ServerStateStore {
90
81
  readonly state: StateDatabase;
91
82
  private readonly ownsState: boolean;
92
83
  readonly workflowMessages: WorkflowMessageStore;
84
+ private readonly attemptTime: AttemptTime;
93
85
 
94
- constructor(databasePath: string, options: { state?: StateDatabase; readOnly?: boolean } = {}) {
86
+ constructor(
87
+ databasePath: string,
88
+ options: { state?: StateDatabase; readOnly?: boolean; clock?: AttemptClock } = {},
89
+ ) {
95
90
  this.ownsState = options.state === undefined;
96
91
  this.state =
97
92
  options.state ??
@@ -100,6 +95,7 @@ export class ServerStateStore {
100
95
  mode: options.readOnly === true ? "read-only" : "read-write",
101
96
  });
102
97
  this.workflowMessages = new WorkflowMessageStore(this.state);
98
+ this.attemptTime = new AttemptTime(this.state, options.clock);
103
99
  }
104
100
 
105
101
  close(): void {
@@ -443,54 +439,10 @@ export class ServerStateStore {
443
439
  kind: InteractiveRequestRecord["kind"];
444
440
  contract: JsonValue;
445
441
  }): InteractiveRequestRecord {
446
- const now = Date.now();
447
442
  return this.state.transaction(() => {
448
- const existing = this.interactiveRequest(options.requestId);
449
- if (existing !== undefined) {
450
- if (
451
- existing.runId !== options.runId ||
452
- existing.attemptId !== options.attemptId ||
453
- canonicalJson(existing.contract) !== canonicalJson(options.contract)
454
- ) {
455
- throw new Error(`Interactive request conflicts: ${options.requestId}`);
456
- }
457
- if (existing.status === "settled" && existing.consumedAt === null) {
458
- this.state.connection
459
- .prepare(
460
- `UPDATE interactive_requests
461
- SET status = 'pending', accepted_submission_id = NULL,
462
- settled_at = NULL, consumed_at = NULL, revision = revision + 1, updated_at = ?
463
- WHERE request_id = ? AND status = 'settled' AND consumed_at IS NULL`,
464
- )
465
- .run(now, options.requestId);
466
- const reopened = this.requireInteractiveRequest(options.requestId);
467
- this.ensureInteractionMessage(reopened, "resumed", now);
468
- return reopened;
469
- }
470
- if (existing.status === "pending") this.ensureInteractionMessage(existing, "initial", now);
471
- return existing;
472
- }
473
- const contractHash = this.state.putJson(options.contract, now);
474
- this.state.connection
475
- .prepare(
476
- `INSERT INTO interactive_requests(
477
- request_id, run_id, attempt_id, target_session_id, kind, contract_hash,
478
- revision, status, created_at, updated_at
479
- ) VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`,
480
- )
481
- .run(
482
- options.requestId,
483
- options.runId,
484
- options.attemptId,
485
- options.targetSessionId,
486
- options.kind,
487
- contractHash,
488
- now,
489
- now,
490
- );
491
- const created = this.requireInteractiveRequest(options.requestId);
492
- this.ensureInteractionMessage(created, "initial", now);
493
- return created;
443
+ const request = ensureWorkflowRequest(this.state, options);
444
+ if (request.status === "pending") this.ensureInteractionMessage(request, "initial");
445
+ return request;
494
446
  });
495
447
  }
496
448
 
@@ -499,12 +451,14 @@ export class ServerStateStore {
499
451
  reason: WorkflowStepReason,
500
452
  now: number = Date.now(),
501
453
  ) {
502
- const kind = request.kind === "decision" ? "decision" : "step";
454
+ const kind = request.kind === "decision" || request.kind === "checkpoint" ? "decision" : "step";
503
455
  const idempotencyKey = `${request.revision}:${reason}`;
504
456
  const workflowMessageId = workflowMessageIdFor(kind, request.requestId, idempotencyKey);
505
457
  const content =
506
458
  kind === "decision"
507
- ? decisionWorkflowMessageContent({
459
+ ? (request.kind === "checkpoint"
460
+ ? checkpointWorkflowMessageContent
461
+ : decisionWorkflowMessageContent)({
508
462
  workflowMessageId,
509
463
  requestId: request.requestId,
510
464
  runId: request.runId,
@@ -538,7 +492,7 @@ export class ServerStateStore {
538
492
  .get(runId);
539
493
  if (!isRequestIdRow(row)) throw new Error("Pending workflow interaction is missing");
540
494
  const current = this.requireInteractiveRequest(row.requestId);
541
- if (current.kind === "decision") return current;
495
+ if (current.kind === "decision" || current.kind === "checkpoint") return current;
542
496
  this.workflowMessages.cancelPendingForSource(current.requestId, "step", now);
543
497
  const changed = this.state.connection
544
498
  .prepare(
@@ -553,144 +507,92 @@ export class ServerStateStore {
553
507
  });
554
508
  }
555
509
 
556
- beginInteractionModelTurn(requestId: string, now: number = Date.now()): void {
557
- const row = this.state.connection
558
- .prepare(
559
- `SELECT i.attempt_id AS attemptId, i.created_at AS createdAt,
560
- a.started_at AS startedAt, a.deadline_at AS deadlineAt,
561
- (SELECT MAX(t.ended_at)
562
- FROM workflow_messages m
563
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
564
- WHERE m.source_id = i.request_id AND m.kind = 'step'
565
- AND t.state = 'ended') AS previousTurnEndedAt
566
- FROM interactive_requests i
567
- JOIN node_attempts a ON a.attempt_id = i.attempt_id
568
- WHERE i.request_id = ? AND i.status = 'pending'`,
569
- )
570
- .get(requestId);
571
- if (!isInteractionTimeoutRow(row)) {
572
- throw new Error("Pending workflow interaction timeout is missing");
573
- }
574
- this.shiftInteractionTimeout(
575
- row,
576
- Math.max(0, now - (row.previousTurnEndedAt ?? row.startedAt ?? row.createdAt)),
577
- now,
578
- );
510
+ syncActiveTime(): void {
511
+ this.attemptTime.sample();
579
512
  }
580
513
 
581
- interactionPauseStartedAt(runId: string): number | undefined {
582
- const row = this.state.connection
583
- .prepare(
584
- `SELECT r.updated_at AS pausedAt
585
- FROM runs r
586
- JOIN interactive_requests i ON i.run_id = r.run_id AND i.status = 'pending'
587
- WHERE r.run_id = ? AND r.paused = 1 LIMIT 1`,
588
- )
589
- .get(runId);
590
- return isPausedAtRow(row) ? row.pausedAt : undefined;
514
+ beginInteractionModelTurn(requestId: string): void {
515
+ const request = this.requireInteractiveRequest(requestId);
516
+ if (request.status !== "pending" || !["agent", "assistant"].includes(request.kind)) {
517
+ throw new Error("Only a pending agent request can start active model time");
518
+ }
519
+ const run = this.state.connection
520
+ .prepare("SELECT paused FROM runs WHERE run_id = ?")
521
+ .get(request.runId) as { paused: number };
522
+ if (run.paused !== 0) throw new Error("Paused workflow cannot start active model time");
523
+ this.attemptTime.start(request.attemptId);
591
524
  }
592
525
 
593
- resumeInteractionModelTurn(
594
- runId: string,
595
- pausedAt: number | undefined,
596
- now: number = Date.now(),
597
- ): void {
598
- if (pausedAt === undefined) return;
599
- const row = this.state.connection
600
- .prepare(
601
- `SELECT i.attempt_id AS attemptId, i.created_at AS createdAt,
602
- a.started_at AS startedAt, a.deadline_at AS deadlineAt,
603
- t.ended_at AS previousTurnEndedAt
604
- FROM interactive_requests i
605
- JOIN node_attempts a ON a.attempt_id = i.attempt_id
606
- JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
607
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
608
- WHERE i.run_id = ? AND i.status = 'pending' AND t.started_at <= ?
609
- AND (t.ended_at IS NULL OR t.ended_at > ?)
610
- ORDER BY t.started_at DESC LIMIT 1`,
611
- )
612
- .get(runId, pausedAt, pausedAt);
613
- if (!isInteractionTimeoutRow(row)) return;
614
- const modelTurnStoppedAt = Math.min(now, row.previousTurnEndedAt ?? now);
615
- this.shiftInteractionTimeout(row, Math.max(0, modelTurnStoppedAt - pausedAt), now);
526
+ endInteractionModelTurn(requestId: string): void {
527
+ this.attemptTime.stop(this.requireInteractiveRequest(requestId).attemptId);
616
528
  }
617
529
 
618
- markSessionModelTurnsInactive(targetSessionId: string, now: number = Date.now()): void {
619
- this.state.connection
530
+ resumeInteractionModelTurn(runId: string): void {
531
+ const rows = this.state.connection
620
532
  .prepare(
621
- `UPDATE node_attempts SET status = 'interrupted', updated_at = ?
622
- WHERE status <> 'interrupted' AND attempt_id IN (
623
- SELECT DISTINCT i.attempt_id
624
- FROM interactive_requests i
625
- JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
626
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
627
- AND t.state = 'started'
628
- WHERE i.target_session_id = ? AND i.status = 'pending'
629
- )`,
533
+ `SELECT DISTINCT i.attempt_id AS attemptId FROM interactive_requests i
534
+ JOIN runs r ON r.run_id = i.run_id
535
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
536
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id AND t.state = 'started'
537
+ WHERE i.run_id = ? AND i.status = 'pending' AND r.paused = 0`,
630
538
  )
631
- .run(now, targetSessionId);
539
+ .all(runId) as { attemptId: string }[];
540
+ for (const row of rows) {
541
+ this.attemptTime.start(row.attemptId);
542
+ this.state.connection
543
+ .prepare("UPDATE node_attempts SET status = 'waiting' WHERE attempt_id = ?")
544
+ .run(row.attemptId);
545
+ }
632
546
  }
633
547
 
634
- recoverInactiveModelTurns(
635
- previousHeartbeatAt: number | undefined,
636
- now: number = Date.now(),
637
- ): void {
548
+ markSessionModelTurnsInactive(targetSessionId: string, now: number = Date.now()): void {
638
549
  const rows = this.state.connection
639
550
  .prepare(
640
- `SELECT i.attempt_id AS attemptId, a.status, a.started_at AS startedAt,
641
- a.deadline_at AS deadlineAt, a.updated_at AS updatedAt,
642
- MAX(t.started_at) AS turnStartedAt
643
- FROM interactive_requests i
644
- JOIN node_attempts a ON a.attempt_id = i.attempt_id
645
- JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
646
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
647
- AND t.state = 'started'
648
- WHERE i.status = 'pending'
649
- GROUP BY i.attempt_id`,
551
+ `SELECT DISTINCT i.attempt_id AS attemptId FROM interactive_requests i
552
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
553
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id AND t.state = 'started'
554
+ WHERE i.target_session_id = ? AND i.status = 'pending'`,
650
555
  )
651
- .all()
652
- .filter(isInactiveInteractionTimeoutRow);
556
+ .all(targetSessionId) as { attemptId: string }[];
653
557
  for (const row of rows) {
654
- const inactiveAt =
655
- row.status === "interrupted"
656
- ? row.updatedAt
657
- : previousHeartbeatAt === undefined
658
- ? row.updatedAt
659
- : Math.max(previousHeartbeatAt, row.turnStartedAt);
660
- this.shiftInteractionTimeout(row, Math.max(0, now - inactiveAt), now);
558
+ this.attemptTime.stop(row.attemptId);
661
559
  this.state.connection
662
560
  .prepare(
663
- `UPDATE node_attempts SET status = 'interrupted', updated_at = ?
664
- WHERE attempt_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
561
+ "UPDATE node_attempts SET status = 'interrupted', updated_at = ? WHERE attempt_id = ?",
665
562
  )
666
563
  .run(now, row.attemptId);
667
564
  }
668
565
  }
669
566
 
567
+ recoverInactiveModelTurns(now: number = Date.now()): void {
568
+ this.attemptTime.recover();
569
+ this.state.connection
570
+ .prepare(
571
+ `UPDATE node_attempts SET status = 'interrupted', updated_at = ? WHERE attempt_id IN (
572
+ SELECT i.attempt_id FROM interactive_requests i
573
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
574
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id AND t.state = 'started'
575
+ WHERE i.status = 'pending'
576
+ )`,
577
+ )
578
+ .run(now);
579
+ }
580
+
670
581
  resumeSessionModelTurns(targetSessionId: string, now: number = Date.now()): void {
671
582
  const rows = this.state.connection
672
583
  .prepare(
673
- `SELECT i.attempt_id AS attemptId, a.status, a.started_at AS startedAt,
674
- a.deadline_at AS deadlineAt, a.updated_at AS updatedAt,
675
- MAX(t.started_at) AS turnStartedAt
676
- FROM interactive_requests i
677
- JOIN node_attempts a ON a.attempt_id = i.attempt_id
678
- JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
679
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
680
- AND t.state = 'started'
681
- WHERE i.target_session_id = ? AND i.status = 'pending'
682
- AND a.status = 'interrupted'
683
- GROUP BY i.attempt_id`,
584
+ `SELECT DISTINCT i.attempt_id AS attemptId FROM interactive_requests i
585
+ JOIN runs r ON r.run_id = i.run_id
586
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
587
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
588
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id AND t.state = 'started'
589
+ WHERE i.target_session_id = ? AND i.status = 'pending' AND r.paused = 0 AND a.status = 'interrupted'`,
684
590
  )
685
- .all(targetSessionId)
686
- .filter(isInactiveInteractionTimeoutRow);
591
+ .all(targetSessionId) as { attemptId: string }[];
687
592
  for (const row of rows) {
688
- this.shiftInteractionTimeout(row, Math.max(0, now - row.updatedAt), now);
593
+ this.attemptTime.start(row.attemptId);
689
594
  this.state.connection
690
- .prepare(
691
- `UPDATE node_attempts SET status = 'waiting', updated_at = ?
692
- WHERE attempt_id = ? AND status = 'interrupted'`,
693
- )
595
+ .prepare("UPDATE node_attempts SET status = 'waiting', updated_at = ? WHERE attempt_id = ?")
694
596
  .run(now, row.attemptId);
695
597
  }
696
598
  }
@@ -760,14 +662,18 @@ export class ServerStateStore {
760
662
  };
761
663
  }
762
664
 
763
- validatingInteractionRunIds(): string[] {
665
+ readyInteractionRunIds(): string[] {
764
666
  return this.state.connection
765
667
  .prepare(
766
668
  `SELECT i.run_id AS runId
767
669
  FROM interactive_requests i
768
670
  JOIN interactive_submissions s ON s.request_id = i.request_id
769
671
  JOIN run_queue q ON q.run_id = i.run_id
770
- WHERE i.status = 'pending' AND s.outcome = 'validating'
672
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
673
+ WHERE ((i.status = 'pending' AND s.outcome = 'validating')
674
+ OR (i.status = 'settled' AND i.accepted_submission_id = s.submission_id
675
+ AND s.outcome = 'accepted' AND i.consumed_at IS NULL))
676
+ AND a.status IN ('pending', 'running', 'waiting', 'interrupted')
771
677
  AND q.status NOT IN ('done', 'failed', 'cancelled')
772
678
  GROUP BY i.run_id
773
679
  ORDER BY MIN(s.submitted_at), i.run_id`,
@@ -776,26 +682,21 @@ export class ServerStateStore {
776
682
  .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
777
683
  }
778
684
 
779
- expiredInteractionRuns(now: number = Date.now()): ExpiredInteractionRunRow[] {
685
+ expiredInteractionRuns(): ExpiredInteractionRunRow[] {
780
686
  return this.state.connection
781
687
  .prepare(
782
688
  `SELECT i.run_id AS runId, i.target_session_id AS targetSessionId
783
- FROM interactive_requests i
784
- JOIN node_attempts a ON a.attempt_id = i.attempt_id
785
- JOIN runs r ON r.run_id = i.run_id
786
- JOIN run_queue q ON q.run_id = i.run_id
787
- WHERE i.status = 'pending' AND r.paused = 0
788
- AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
789
- AND q.status NOT IN ('done', 'failed', 'cancelled')
790
- AND EXISTS (
791
- SELECT 1 FROM workflow_messages m
792
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
793
- WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
794
- )
795
- GROUP BY i.run_id, i.target_session_id
796
- ORDER BY MIN(a.deadline_at), i.run_id`,
689
+ FROM interactive_requests i
690
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
691
+ JOIN runs r ON r.run_id = i.run_id
692
+ JOIN run_queue q ON q.run_id = i.run_id
693
+ WHERE i.status = 'pending' AND r.paused = 0 AND a.timeout_ms > 0
694
+ AND (SELECT COALESCE(SUM(elapsed_ms), 0) FROM attempt_active_intervals t
695
+ WHERE t.attempt_id = a.attempt_id) >= a.timeout_ms
696
+ AND q.status NOT IN ('done', 'failed', 'cancelled')
697
+ GROUP BY i.run_id, i.target_session_id ORDER BY MIN(a.started_at), i.run_id`,
797
698
  )
798
- .all(now)
699
+ .all()
799
700
  .filter(isExpiredInteractionRunRow);
800
701
  }
801
702
 
@@ -803,20 +704,17 @@ export class ServerStateStore {
803
704
  const row = this.state.connection
804
705
  .prepare(
805
706
  `SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId
806
- FROM interactive_requests i
807
- JOIN node_attempts a ON a.attempt_id = i.attempt_id
808
- JOIN runs r ON r.run_id = i.run_id
809
- WHERE i.run_id = ? AND i.status = 'cancelled' AND r.status = 'running'
810
- AND r.paused = 0 AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
811
- AND a.status IN ('pending', 'running', 'waiting', 'interrupted', 'timed_out')
812
- AND EXISTS (
813
- SELECT 1 FROM workflow_messages m
814
- JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
815
- WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
816
- )
817
- ORDER BY a.deadline_at, i.request_id LIMIT 1`,
707
+ FROM interactive_requests i
708
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
709
+ JOIN runs r ON r.run_id = i.run_id
710
+ WHERE i.run_id = ? AND i.status = 'cancelled' AND r.status = 'running'
711
+ AND r.paused = 0 AND a.timeout_ms > 0
712
+ AND (SELECT COALESCE(SUM(elapsed_ms), 0) FROM attempt_active_intervals t
713
+ WHERE t.attempt_id = a.attempt_id) >= a.timeout_ms
714
+ AND a.status IN ('pending', 'running', 'waiting', 'interrupted', 'timed_out')
715
+ ORDER BY a.started_at, i.request_id LIMIT 1`,
818
716
  )
819
- .get(runId, Date.now());
717
+ .get(runId);
820
718
  return isTimedOutInteractionRow(row) ? row : undefined;
821
719
  }
822
720
 
@@ -890,17 +788,6 @@ export class ServerStateStore {
890
788
  });
891
789
  }
892
790
 
893
- consumeAcceptedInteraction(runId: string, attemptId: string, now: number = Date.now()): boolean {
894
- const changed = this.state.connection
895
- .prepare(
896
- `UPDATE interactive_requests SET consumed_at = ?, updated_at = ?
897
- WHERE run_id = ? AND attempt_id = ? AND status = 'settled'
898
- AND accepted_submission_id IS NOT NULL AND consumed_at IS NULL`,
899
- )
900
- .run(now, now, runId, attemptId);
901
- return changed.changes === 1;
902
- }
903
-
904
791
  listPendingInteractions(sessionId: string): InteractiveRequestRecord[] {
905
792
  return this.state.connection
906
793
  .prepare(
@@ -941,13 +828,23 @@ export class ServerStateStore {
941
828
  outcome: "accepted" | "adopted";
942
829
  receipt: JsonValue;
943
830
  } {
944
- return this.recordInteractionSubmission({
831
+ return recordWorkflowSubmission(this.state, {
945
832
  ...options,
946
833
  outcome: options.accepted ? "accepted" : "rejected",
947
834
  settle: options.accepted,
948
835
  });
949
836
  }
950
837
 
838
+ hasInteractionSubmission(requestId: string, idempotencyKey: string): boolean {
839
+ return (
840
+ this.state.connection
841
+ .prepare(
842
+ "SELECT 1 FROM interactive_submissions WHERE request_id = ? AND idempotency_key = ?",
843
+ )
844
+ .get(requestId, idempotencyKey) !== undefined
845
+ );
846
+ }
847
+
951
848
  beginInteractionValidation(options: {
952
849
  requestId: string;
953
850
  submissionId: string;
@@ -961,111 +858,13 @@ export class ServerStateStore {
961
858
  outcome: "accepted" | "adopted";
962
859
  receipt: JsonValue;
963
860
  } {
964
- return this.recordInteractionSubmission({
861
+ return recordWorkflowSubmission(this.state, {
965
862
  ...options,
966
863
  outcome: "validating",
967
864
  settle: false,
968
865
  });
969
866
  }
970
867
 
971
- private recordInteractionSubmission(options: {
972
- requestId: string;
973
- submissionId: string;
974
- idempotencyKey: string;
975
- expectedRevision: number;
976
- payload: JsonValue;
977
- outcome: InteractiveSubmissionRecord["outcome"];
978
- settle: boolean;
979
- receipt?: JsonValue;
980
- }): {
981
- interaction: InteractiveRequestRecord;
982
- submissionId: string;
983
- outcome: "accepted" | "adopted";
984
- receipt: JsonValue;
985
- } {
986
- const now = Date.now();
987
- return this.state.transaction(() => {
988
- const existing = this.state.connection
989
- .prepare(
990
- `SELECT submission_id AS submissionId, payload_hash AS payloadHash,
991
- outcome, receipt_hash AS receiptHash
992
- FROM interactive_submissions WHERE request_id = ? AND idempotency_key = ?`,
993
- )
994
- .get(options.requestId, options.idempotencyKey);
995
- const payloadHash = createHash("sha256").update(canonicalJson(options.payload)).digest();
996
- if (isSubmissionRow(existing)) {
997
- if (!existing.payloadHash.equals(payloadHash)) {
998
- throw new Error("Interactive submission idempotency key conflicts");
999
- }
1000
- return {
1001
- interaction: this.requireInteractiveRequest(options.requestId),
1002
- submissionId: existing.submissionId,
1003
- outcome: "adopted",
1004
- receipt:
1005
- existing.receiptHash === null
1006
- ? { requestId: options.requestId, submissionId: existing.submissionId }
1007
- : this.state.readJson(existing.receiptHash),
1008
- };
1009
- }
1010
- if (options.outcome === "validating") {
1011
- const active = this.state.connection
1012
- .prepare(
1013
- `SELECT submission_id AS submissionId FROM interactive_submissions
1014
- WHERE request_id = ? AND outcome = 'validating' LIMIT 1`,
1015
- )
1016
- .get(options.requestId);
1017
- if (isSubmissionIdRow(active)) {
1018
- throw new Error("Interactive submission validation is already active");
1019
- }
1020
- }
1021
- const request = this.requireInteractiveRequest(options.requestId);
1022
- if (request.revision !== options.expectedRevision || request.status !== "pending") {
1023
- throw new Error("Interactive request revision conflict");
1024
- }
1025
- const savedPayloadHash = this.state.putJson(options.payload, now);
1026
- const receiptHash =
1027
- options.receipt === undefined ? null : this.state.putJson(options.receipt, now);
1028
- this.state.connection
1029
- .prepare(
1030
- `INSERT INTO interactive_submissions(
1031
- submission_id, request_id, idempotency_key, request_revision,
1032
- payload_hash, outcome, receipt_hash, submitted_at
1033
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1034
- )
1035
- .run(
1036
- options.submissionId,
1037
- options.requestId,
1038
- options.idempotencyKey,
1039
- options.expectedRevision,
1040
- savedPayloadHash,
1041
- options.outcome,
1042
- receiptHash,
1043
- now,
1044
- );
1045
- if (options.settle) {
1046
- this.state.connection
1047
- .prepare(
1048
- `UPDATE interactive_requests
1049
- SET status = 'settled', accepted_submission_id = ?, revision = revision + 1,
1050
- updated_at = ?, settled_at = ?
1051
- WHERE request_id = ? AND revision = ? AND status = 'pending'`,
1052
- )
1053
- .run(options.submissionId, now, now, options.requestId, options.expectedRevision);
1054
- this.workflowMessages.cancelPendingForSource(options.requestId, "step", now);
1055
- this.workflowMessages.cancelPendingForSource(options.requestId, "decision", now);
1056
- }
1057
- return {
1058
- interaction: this.requireInteractiveRequest(options.requestId),
1059
- submissionId: options.submissionId,
1060
- outcome: "accepted",
1061
- receipt: options.receipt ?? {
1062
- requestId: options.requestId,
1063
- submissionId: options.submissionId,
1064
- },
1065
- };
1066
- });
1067
- }
1068
-
1069
868
  private serverRow(): ServerRow {
1070
869
  const row = this.state.connection
1071
870
  .prepare(
@@ -1097,50 +896,7 @@ export class ServerStateStore {
1097
896
  }
1098
897
 
1099
898
  private interactiveRequest(requestId: string): InteractiveRequestRecord | undefined {
1100
- const row = this.state.connection
1101
- .prepare(
1102
- `SELECT request_id AS requestId, run_id AS runId, attempt_id AS attemptId,
1103
- target_session_id AS targetSessionId, kind, contract_hash AS contractHash,
1104
- revision, status, unproductive_turn_ends AS unproductiveTurnEnds,
1105
- accepted_submission_id AS acceptedSubmissionId, created_at AS createdAt,
1106
- updated_at AS updatedAt, settled_at AS settledAt, consumed_at AS consumedAt
1107
- FROM interactive_requests WHERE request_id = ?`,
1108
- )
1109
- .get(requestId);
1110
- if (!isInteractiveRequestRow(row)) return undefined;
1111
- return {
1112
- requestId: row.requestId,
1113
- runId: row.runId,
1114
- attemptId: row.attemptId,
1115
- targetSessionId: row.targetSessionId,
1116
- kind: row.kind,
1117
- contract: this.state.readJson(row.contractHash),
1118
- revision: row.revision,
1119
- status: row.status,
1120
- unproductiveTurnEnds: row.unproductiveTurnEnds,
1121
- acceptedSubmissionId: row.acceptedSubmissionId,
1122
- createdAt: new Date(row.createdAt).toISOString(),
1123
- updatedAt: new Date(row.updatedAt).toISOString(),
1124
- settledAt: iso(row.settledAt),
1125
- consumedAt: iso(row.consumedAt),
1126
- };
1127
- }
1128
-
1129
- private shiftInteractionTimeout(
1130
- row: Pick<InteractionTimeoutRow, "attemptId" | "startedAt" | "deadlineAt">,
1131
- idleMs: number,
1132
- now: number,
1133
- ): void {
1134
- if (idleMs === 0 || row.deadlineAt === null) return;
1135
- const changed = this.state.connection
1136
- .prepare(
1137
- `UPDATE node_attempts
1138
- SET started_at = CASE WHEN started_at IS NULL THEN NULL ELSE started_at + ? END,
1139
- deadline_at = deadline_at + ?, updated_at = ?
1140
- WHERE attempt_id = ? AND deadline_at = ?`,
1141
- )
1142
- .run(idleMs, idleMs, now, row.attemptId, row.deadlineAt);
1143
- if (changed.changes !== 1) throw new Error("Workflow interaction timeout changed concurrently");
899
+ return readWorkflowRequest(this.state, requestId);
1144
900
  }
1145
901
 
1146
902
  private requireInteractiveRequest(requestId: string): InteractiveRequestRecord {
@@ -1191,13 +947,6 @@ type CommandRow = {
1191
947
  type IdempotentCommandRow = CommandRow & { requestId: string };
1192
948
  type RequestIdRow = { requestId: string };
1193
949
  type RunIdRow = { runId: string };
1194
- type SubmissionRow = {
1195
- submissionId: string;
1196
- payloadHash: Buffer;
1197
- outcome: string;
1198
- receiptHash: Buffer | null;
1199
- };
1200
- type SubmissionIdRow = { submissionId: string };
1201
950
  type SubmissionDetailRow = {
1202
951
  requestId: string;
1203
952
  submissionId: string;
@@ -1221,44 +970,12 @@ type AcceptedInteractionRow = {
1221
970
  submissionId: string;
1222
971
  payloadHash: Buffer;
1223
972
  };
973
+ type ExpiredInteractionRunRow = { runId: string; targetSessionId: string };
1224
974
  type TimedOutInteractionRow = {
1225
975
  requestId: string;
1226
976
  attemptId: string;
1227
977
  nodeId: string;
1228
978
  };
1229
- type InteractionTimeoutRow = {
1230
- attemptId: string;
1231
- createdAt: number;
1232
- startedAt: number | null;
1233
- deadlineAt: number | null;
1234
- previousTurnEndedAt: number | null;
1235
- };
1236
- type InactiveInteractionTimeoutRow = {
1237
- attemptId: string;
1238
- status: "pending" | "running" | "waiting" | "interrupted";
1239
- startedAt: number | null;
1240
- deadlineAt: number | null;
1241
- updatedAt: number;
1242
- turnStartedAt: number;
1243
- };
1244
- type ExpiredInteractionRunRow = { runId: string; targetSessionId: string };
1245
- type PausedAtRow = { pausedAt: number };
1246
- type InteractiveRequestRow = {
1247
- requestId: string;
1248
- runId: string;
1249
- attemptId: string;
1250
- targetSessionId: string;
1251
- kind: InteractiveRequestRecord["kind"];
1252
- contractHash: Buffer;
1253
- revision: number;
1254
- status: InteractiveRequestRecord["status"];
1255
- unproductiveTurnEnds: number;
1256
- acceptedSubmissionId: string | null;
1257
- createdAt: number;
1258
- updatedAt: number;
1259
- settledAt: number | null;
1260
- consumedAt: number | null;
1261
- };
1262
979
 
1263
980
  function isServerRow(value: unknown): value is ServerRow {
1264
981
  return (
@@ -1332,53 +1049,12 @@ function isTimedOutInteractionRow(value: unknown): value is TimedOutInteractionR
1332
1049
  );
1333
1050
  }
1334
1051
 
1335
- function isInteractionTimeoutRow(value: unknown): value is InteractionTimeoutRow {
1336
- return (
1337
- isRecord(value) &&
1338
- typeof value.attemptId === "string" &&
1339
- typeof value.createdAt === "number" &&
1340
- nullableNumber(value.startedAt) &&
1341
- nullableNumber(value.deadlineAt) &&
1342
- nullableNumber(value.previousTurnEndedAt)
1343
- );
1344
- }
1345
-
1346
- function isInactiveInteractionTimeoutRow(value: unknown): value is InactiveInteractionTimeoutRow {
1347
- return (
1348
- isRecord(value) &&
1349
- typeof value.attemptId === "string" &&
1350
- ["pending", "running", "waiting", "interrupted"].includes(value.status as string) &&
1351
- nullableNumber(value.startedAt) &&
1352
- nullableNumber(value.deadlineAt) &&
1353
- typeof value.updatedAt === "number" &&
1354
- typeof value.turnStartedAt === "number"
1355
- );
1356
- }
1357
-
1358
1052
  function isExpiredInteractionRunRow(value: unknown): value is ExpiredInteractionRunRow {
1359
1053
  return (
1360
1054
  isRecord(value) && typeof value.runId === "string" && typeof value.targetSessionId === "string"
1361
1055
  );
1362
1056
  }
1363
1057
 
1364
- function isPausedAtRow(value: unknown): value is PausedAtRow {
1365
- return isRecord(value) && typeof value.pausedAt === "number";
1366
- }
1367
-
1368
- function isSubmissionRow(value: unknown): value is SubmissionRow {
1369
- return (
1370
- isRecord(value) &&
1371
- typeof value.submissionId === "string" &&
1372
- Buffer.isBuffer(value.payloadHash) &&
1373
- typeof value.outcome === "string" &&
1374
- (value.receiptHash === null || Buffer.isBuffer(value.receiptHash))
1375
- );
1376
- }
1377
-
1378
- function isSubmissionIdRow(value: unknown): value is SubmissionIdRow {
1379
- return isRecord(value) && typeof value.submissionId === "string";
1380
- }
1381
-
1382
1058
  function isSubmissionDetailRow(value: unknown): value is SubmissionDetailRow {
1383
1059
  return (
1384
1060
  isRecord(value) &&
@@ -1392,26 +1068,6 @@ function isSubmissionDetailRow(value: unknown): value is SubmissionDetailRow {
1392
1068
  );
1393
1069
  }
1394
1070
 
1395
- function isInteractiveRequestRow(value: unknown): value is InteractiveRequestRow {
1396
- return (
1397
- isRecord(value) &&
1398
- typeof value.requestId === "string" &&
1399
- typeof value.runId === "string" &&
1400
- typeof value.attemptId === "string" &&
1401
- typeof value.targetSessionId === "string" &&
1402
- ["agent", "assistant", "decision"].includes(value.kind as string) &&
1403
- Buffer.isBuffer(value.contractHash) &&
1404
- typeof value.revision === "number" &&
1405
- ["pending", "settled", "cancelled"].includes(value.status as string) &&
1406
- typeof value.unproductiveTurnEnds === "number" &&
1407
- nullableString(value.acceptedSubmissionId) &&
1408
- typeof value.createdAt === "number" &&
1409
- typeof value.updatedAt === "number" &&
1410
- nullableNumber(value.settledAt) &&
1411
- nullableNumber(value.consumedAt)
1412
- );
1413
- }
1414
-
1415
1071
  function runnerMessageFingerprint(message: WorkflowRunnerMessage): Buffer {
1416
1072
  return createHash("sha256").update(canonicalJson(message)).digest();
1417
1073
  }