@osolmaz/pi-workflows 0.16.0 → 0.16.1

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 (154) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +42 -16
  22. package/dist/client/view.js.map +1 -1
  23. package/dist/controllers/index.d.ts +1 -1
  24. package/dist/controllers/index.js.map +1 -1
  25. package/dist/controllers/sqlite.d.ts +12 -165
  26. package/dist/controllers/sqlite.js +124 -435
  27. package/dist/controllers/sqlite.js.map +1 -1
  28. package/dist/extension/index.d.ts +23 -2
  29. package/dist/extension/index.js +619 -535
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/recorder.d.ts +95 -0
  32. package/dist/extension/recorder.js +530 -0
  33. package/dist/extension/recorder.js.map +1 -0
  34. package/dist/extension/remote-recorder-store.d.ts +25 -0
  35. package/dist/extension/remote-recorder-store.js +81 -0
  36. package/dist/extension/remote-recorder-store.js.map +1 -0
  37. package/dist/extension/session-events.d.ts +134 -0
  38. package/dist/extension/session-events.js +60 -0
  39. package/dist/extension/session-events.js.map +1 -0
  40. package/dist/extension/session-view.d.ts +2 -0
  41. package/dist/extension/session-view.js +60 -2
  42. package/dist/extension/session-view.js.map +1 -1
  43. package/dist/extension/step-message.d.ts +6 -4
  44. package/dist/extension/step-message.js +12 -6
  45. package/dist/extension/step-message.js.map +1 -1
  46. package/dist/extension/widget.d.ts +2 -2
  47. package/dist/extension/widget.js +28 -18
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  50. package/dist/extension/workflow-message-coordinator.js +294 -0
  51. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  52. package/dist/host/channel-effects.d.ts +72 -0
  53. package/dist/host/channel-effects.js +271 -0
  54. package/dist/host/channel-effects.js.map +1 -0
  55. package/dist/host/channel-supervisor.d.ts +21 -0
  56. package/dist/host/channel-supervisor.js +54 -0
  57. package/dist/host/channel-supervisor.js.map +1 -0
  58. package/dist/host/runner.d.ts +43 -8
  59. package/dist/host/runner.js +1567 -331
  60. package/dist/host/runner.js.map +1 -1
  61. package/dist/host/state.d.ts +19 -16
  62. package/dist/host/state.js +251 -63
  63. package/dist/host/state.js.map +1 -1
  64. package/dist/host/view.d.ts +19 -9
  65. package/dist/host/view.js +222 -118
  66. package/dist/host/view.js.map +1 -1
  67. package/dist/state/prune.js +3 -25
  68. package/dist/state/prune.js.map +1 -1
  69. package/dist/state/schema.js +76 -97
  70. package/dist/state/schema.js.map +1 -1
  71. package/dist/state/workflow-messages.d.ts +96 -0
  72. package/dist/state/workflow-messages.js +335 -0
  73. package/dist/state/workflow-messages.js.map +1 -0
  74. package/dist/viewer/render.js +1 -1
  75. package/dist/viewer/render.js.map +1 -1
  76. package/dist/viewer/tui.d.ts +1 -1
  77. package/dist/viewer/tui.js +65 -8
  78. package/dist/viewer/tui.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +2 -0
  80. package/dist/workflows/human-decision.js +8 -2
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +1 -1
  83. package/dist/workflows/index.js.map +1 -1
  84. package/dist/workflows/settings.d.ts +1 -8
  85. package/dist/workflows/settings.js.map +1 -1
  86. package/dist/workflows/store.d.ts +5 -19
  87. package/dist/workflows/store.js +121 -354
  88. package/dist/workflows/store.js.map +1 -1
  89. package/dist/workflows/tool-input.d.ts +21 -0
  90. package/dist/workflows/tool-input.js +23 -1
  91. package/dist/workflows/tool-input.js.map +1 -1
  92. package/dist/workflows/types.d.ts +5 -5
  93. package/dist/workflows/workflow-message-content.d.ts +38 -0
  94. package/dist/workflows/workflow-message-content.js +157 -0
  95. package/dist/workflows/workflow-message-content.js.map +1 -0
  96. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  97. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  98. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  99. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  100. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  101. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  102. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  103. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  104. package/docs/DEFERRED_TURNS.md +66 -294
  105. package/docs/HUMAN_DECISIONS.md +29 -30
  106. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  107. package/docs/SQLITE_STATE.md +19 -15
  108. package/docs/WORKFLOW_HOST.md +93 -28
  109. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  110. package/docs/live-replay-protocol.md +8 -4
  111. package/docs/tui-viewer.md +3 -1
  112. package/docs/workflows.md +34 -27
  113. package/herdr-plugin.toml +1 -1
  114. package/package.json +1 -1
  115. package/plugins/herdr/viewer.mjs +14 -5
  116. package/src/channels/adapter-entry.ts +220 -0
  117. package/src/channels/config.ts +296 -0
  118. package/src/channels/protocol.ts +333 -0
  119. package/src/channels/telegram.ts +335 -0
  120. package/src/client/client.ts +6 -2
  121. package/src/client/index.ts +2 -1
  122. package/src/client/protocol.ts +11 -5
  123. package/src/client/view.ts +43 -18
  124. package/src/controllers/index.ts +0 -1
  125. package/src/controllers/sqlite.ts +164 -844
  126. package/src/extension/index.ts +693 -646
  127. package/src/extension/recorder.ts +687 -0
  128. package/src/extension/remote-recorder-store.ts +126 -0
  129. package/src/extension/session-events.ts +119 -0
  130. package/src/extension/session-view.ts +86 -3
  131. package/src/extension/step-message.ts +19 -9
  132. package/src/extension/widget.ts +37 -17
  133. package/src/extension/workflow-message-coordinator.ts +334 -0
  134. package/src/host/channel-effects.ts +465 -0
  135. package/src/host/channel-supervisor.ts +73 -0
  136. package/src/host/runner.ts +2120 -531
  137. package/src/host/state.ts +333 -94
  138. package/src/host/view.ts +249 -121
  139. package/src/state/prune.ts +3 -31
  140. package/src/state/schema.ts +76 -97
  141. package/src/state/workflow-messages.ts +548 -0
  142. package/src/viewer/render.ts +1 -5
  143. package/src/viewer/tui.ts +71 -6
  144. package/src/workflows/human-decision.ts +18 -2
  145. package/src/workflows/index.ts +0 -1
  146. package/src/workflows/settings.ts +1 -20
  147. package/src/workflows/store.ts +173 -489
  148. package/src/workflows/tool-input.ts +36 -1
  149. package/src/workflows/types.ts +5 -5
  150. package/src/workflows/workflow-message-content.ts +197 -0
  151. package/dist/extension/decision-channels.d.ts +0 -134
  152. package/dist/extension/decision-channels.js +0 -1307
  153. package/dist/extension/decision-channels.js.map +0 -1
  154. package/src/extension/decision-channels.ts +0 -1826
package/src/host/state.ts CHANGED
@@ -8,6 +8,15 @@ import {
8
8
  import { StateDatabase } from "../state/database.js";
9
9
  import { canonicalJson, type JsonValue } from "../state/json.js";
10
10
  import { tokenHash } from "../state/mutation.js";
11
+ import {
12
+ WorkflowMessageStore,
13
+ workflowMessageIdFor,
14
+ type WorkflowStepReason,
15
+ } from "../state/workflow-messages.js";
16
+ import {
17
+ decisionWorkflowMessageContent,
18
+ stepWorkflowMessageContent,
19
+ } from "../workflows/workflow-message-content.js";
11
20
  import type { WorkerMessage, WorkerResponse } from "./worker-protocol.js";
12
21
 
13
22
  export type HostClaim = {
@@ -68,10 +77,8 @@ export type InteractiveRequestRecord = {
68
77
  kind: "agent" | "assistant" | "decision";
69
78
  contract: JsonValue;
70
79
  revision: number;
71
- status: "pending" | "presenting" | "settled" | "cancelled";
72
- presenterId: string | null;
73
- presentationClaimExpiresAt: string | null;
74
- presentationSessionEntryId: string | null;
80
+ status: "pending" | "settled" | "cancelled";
81
+ unproductiveTurnEnds: number;
75
82
  acceptedSubmissionId: string | null;
76
83
  createdAt: string;
77
84
  updatedAt: string;
@@ -82,6 +89,7 @@ export type InteractiveRequestRecord = {
82
89
  export class HostStateStore {
83
90
  readonly state: StateDatabase;
84
91
  private readonly ownsState: boolean;
92
+ readonly workflowMessages: WorkflowMessageStore;
85
93
 
86
94
  constructor(databasePath: string, options: { state?: StateDatabase; readOnly?: boolean } = {}) {
87
95
  this.ownsState = options.state === undefined;
@@ -91,6 +99,7 @@ export class HostStateStore {
91
99
  filePath: databasePath,
92
100
  mode: options.readOnly === true ? "read-only" : "read-write",
93
101
  });
102
+ this.workflowMessages = new WorkflowMessageStore(this.state);
94
103
  }
95
104
 
96
105
  close(): void {
@@ -438,15 +447,16 @@ export class HostStateStore {
438
447
  this.state.connection
439
448
  .prepare(
440
449
  `UPDATE interactive_requests
441
- SET status = 'pending', presenter_id = NULL,
442
- presentation_claim_expires_at = NULL, presentation_session_entry_id = NULL,
443
- accepted_submission_id = NULL, settled_at = NULL, consumed_at = NULL,
444
- revision = revision + 1, updated_at = ?
450
+ SET status = 'pending', accepted_submission_id = NULL,
451
+ settled_at = NULL, consumed_at = NULL, revision = revision + 1, updated_at = ?
445
452
  WHERE request_id = ? AND status = 'settled' AND consumed_at IS NULL`,
446
453
  )
447
454
  .run(now, options.requestId);
448
- return this.requireInteractiveRequest(options.requestId);
455
+ const reopened = this.requireInteractiveRequest(options.requestId);
456
+ this.ensureInteractionMessage(reopened, "resumed", now);
457
+ return reopened;
449
458
  }
459
+ if (existing.status === "pending") this.ensureInteractionMessage(existing, "initial", now);
450
460
  return existing;
451
461
  }
452
462
  const contractHash = this.state.putJson(options.contract, now);
@@ -467,10 +477,213 @@ export class HostStateStore {
467
477
  now,
468
478
  now,
469
479
  );
470
- return this.requireInteractiveRequest(options.requestId);
480
+ const created = this.requireInteractiveRequest(options.requestId);
481
+ this.ensureInteractionMessage(created, "initial", now);
482
+ return created;
483
+ });
484
+ }
485
+
486
+ ensureInteractionMessage(
487
+ request: InteractiveRequestRecord,
488
+ reason: WorkflowStepReason,
489
+ now: number = Date.now(),
490
+ ) {
491
+ const kind = request.kind === "decision" ? "decision" : "step";
492
+ const idempotencyKey = `${request.revision}:${reason}`;
493
+ const workflowMessageId = workflowMessageIdFor(kind, request.requestId, idempotencyKey);
494
+ const content =
495
+ kind === "decision"
496
+ ? decisionWorkflowMessageContent({
497
+ workflowMessageId,
498
+ requestId: request.requestId,
499
+ runId: request.runId,
500
+ contract: request.contract,
501
+ })
502
+ : stepWorkflowMessageContent({
503
+ workflowMessageId,
504
+ requestId: request.requestId,
505
+ contract: request.contract,
506
+ reason,
507
+ });
508
+ return this.workflowMessages.create({
509
+ workflowMessageId,
510
+ runId: request.runId,
511
+ targetSessionId: request.targetSessionId,
512
+ kind,
513
+ sourceId: request.requestId,
514
+ idempotencyKey,
515
+ content,
516
+ now,
517
+ });
518
+ }
519
+
520
+ resumePendingInteraction(runId: string, now: number = Date.now()): InteractiveRequestRecord {
521
+ return this.state.transaction(() => {
522
+ const row = this.state.connection
523
+ .prepare(
524
+ `SELECT request_id AS requestId FROM interactive_requests
525
+ WHERE run_id = ? AND status = 'pending' ORDER BY created_at LIMIT 1`,
526
+ )
527
+ .get(runId);
528
+ if (!isRequestIdRow(row)) throw new Error("Pending workflow interaction is missing");
529
+ const current = this.requireInteractiveRequest(row.requestId);
530
+ if (current.kind === "decision") return current;
531
+ this.workflowMessages.cancelPendingForSource(current.requestId, "step", now);
532
+ const changed = this.state.connection
533
+ .prepare(
534
+ `UPDATE interactive_requests SET revision = revision + 1, updated_at = ?
535
+ WHERE request_id = ? AND status = 'pending'`,
536
+ )
537
+ .run(now, row.requestId);
538
+ if (changed.changes !== 1) throw new Error("Pending workflow interaction resume is stale");
539
+ const request = this.requireInteractiveRequest(row.requestId);
540
+ this.ensureInteractionMessage(request, "resumed", now);
541
+ return request;
471
542
  });
472
543
  }
473
544
 
545
+ beginInteractionModelTurn(requestId: string, now: number = Date.now()): void {
546
+ const row = this.state.connection
547
+ .prepare(
548
+ `SELECT i.attempt_id AS attemptId, i.created_at AS createdAt,
549
+ a.started_at AS startedAt, a.deadline_at AS deadlineAt,
550
+ (SELECT MAX(t.ended_at)
551
+ FROM workflow_messages m
552
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
553
+ WHERE m.source_id = i.request_id AND m.kind = 'step'
554
+ AND t.state = 'ended') AS previousTurnEndedAt
555
+ FROM interactive_requests i
556
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
557
+ WHERE i.request_id = ? AND i.status = 'pending'`,
558
+ )
559
+ .get(requestId);
560
+ if (!isInteractionTimeoutRow(row)) {
561
+ throw new Error("Pending workflow interaction timeout is missing");
562
+ }
563
+ this.shiftInteractionTimeout(
564
+ row,
565
+ Math.max(0, now - (row.previousTurnEndedAt ?? row.startedAt ?? row.createdAt)),
566
+ now,
567
+ );
568
+ }
569
+
570
+ interactionPauseStartedAt(runId: string): number | undefined {
571
+ const row = this.state.connection
572
+ .prepare(
573
+ `SELECT r.updated_at AS pausedAt
574
+ FROM runs r
575
+ JOIN interactive_requests i ON i.run_id = r.run_id AND i.status = 'pending'
576
+ WHERE r.run_id = ? AND r.paused = 1 LIMIT 1`,
577
+ )
578
+ .get(runId);
579
+ return isPausedAtRow(row) ? row.pausedAt : undefined;
580
+ }
581
+
582
+ resumeInteractionModelTurn(
583
+ runId: string,
584
+ pausedAt: number | undefined,
585
+ now: number = Date.now(),
586
+ ): void {
587
+ if (pausedAt === undefined) return;
588
+ const row = this.state.connection
589
+ .prepare(
590
+ `SELECT i.attempt_id AS attemptId, i.created_at AS createdAt,
591
+ a.started_at AS startedAt, a.deadline_at AS deadlineAt,
592
+ t.ended_at AS previousTurnEndedAt
593
+ FROM interactive_requests i
594
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
595
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
596
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
597
+ WHERE i.run_id = ? AND i.status = 'pending' AND t.started_at <= ?
598
+ AND (t.ended_at IS NULL OR t.ended_at > ?)
599
+ ORDER BY t.started_at DESC LIMIT 1`,
600
+ )
601
+ .get(runId, pausedAt, pausedAt);
602
+ if (!isInteractionTimeoutRow(row)) return;
603
+ const modelTurnStoppedAt = Math.min(now, row.previousTurnEndedAt ?? now);
604
+ this.shiftInteractionTimeout(row, Math.max(0, modelTurnStoppedAt - pausedAt), now);
605
+ }
606
+
607
+ markSessionModelTurnsInactive(targetSessionId: string, now: number = Date.now()): void {
608
+ this.state.connection
609
+ .prepare(
610
+ `UPDATE node_attempts SET status = 'interrupted', updated_at = ?
611
+ WHERE status <> 'interrupted' AND attempt_id IN (
612
+ SELECT DISTINCT i.attempt_id
613
+ FROM interactive_requests i
614
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
615
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
616
+ AND t.state = 'started'
617
+ WHERE i.target_session_id = ? AND i.status = 'pending'
618
+ )`,
619
+ )
620
+ .run(now, targetSessionId);
621
+ }
622
+
623
+ recoverInactiveModelTurns(
624
+ previousHeartbeatAt: number | undefined,
625
+ now: number = Date.now(),
626
+ ): void {
627
+ const rows = this.state.connection
628
+ .prepare(
629
+ `SELECT i.attempt_id AS attemptId, a.status, a.started_at AS startedAt,
630
+ a.deadline_at AS deadlineAt, a.updated_at AS updatedAt,
631
+ MAX(t.started_at) AS turnStartedAt
632
+ FROM interactive_requests i
633
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
634
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
635
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
636
+ AND t.state = 'started'
637
+ WHERE i.status = 'pending'
638
+ GROUP BY i.attempt_id`,
639
+ )
640
+ .all()
641
+ .filter(isInactiveInteractionTimeoutRow);
642
+ for (const row of rows) {
643
+ const inactiveAt =
644
+ row.status === "interrupted"
645
+ ? row.updatedAt
646
+ : previousHeartbeatAt === undefined
647
+ ? row.updatedAt
648
+ : Math.max(previousHeartbeatAt, row.turnStartedAt);
649
+ this.shiftInteractionTimeout(row, Math.max(0, now - inactiveAt), now);
650
+ this.state.connection
651
+ .prepare(
652
+ `UPDATE node_attempts SET status = 'interrupted', updated_at = ?
653
+ WHERE attempt_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
654
+ )
655
+ .run(now, row.attemptId);
656
+ }
657
+ }
658
+
659
+ resumeSessionModelTurns(targetSessionId: string, now: number = Date.now()): void {
660
+ const rows = this.state.connection
661
+ .prepare(
662
+ `SELECT i.attempt_id AS attemptId, a.status, a.started_at AS startedAt,
663
+ a.deadline_at AS deadlineAt, a.updated_at AS updatedAt,
664
+ MAX(t.started_at) AS turnStartedAt
665
+ FROM interactive_requests i
666
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
667
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
668
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
669
+ AND t.state = 'started'
670
+ WHERE i.target_session_id = ? AND i.status = 'pending'
671
+ AND a.status = 'interrupted'
672
+ GROUP BY i.attempt_id`,
673
+ )
674
+ .all(targetSessionId)
675
+ .filter(isInactiveInteractionTimeoutRow);
676
+ for (const row of rows) {
677
+ this.shiftInteractionTimeout(row, Math.max(0, now - row.updatedAt), now);
678
+ this.state.connection
679
+ .prepare(
680
+ `UPDATE node_attempts SET status = 'waiting', updated_at = ?
681
+ WHERE attempt_id = ? AND status = 'interrupted'`,
682
+ )
683
+ .run(now, row.attemptId);
684
+ }
685
+ }
686
+
474
687
  getInteraction(requestId: string): InteractiveRequestRecord | undefined {
475
688
  return this.interactiveRequest(requestId);
476
689
  }
@@ -521,7 +734,7 @@ export class HostStateStore {
521
734
  FROM interactive_requests i
522
735
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
523
736
  JOIN interactive_submissions s ON s.request_id = i.request_id
524
- WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
737
+ WHERE i.run_id = ? AND i.status = 'pending'
525
738
  AND s.outcome = 'validating'
526
739
  ORDER BY s.submitted_at DESC LIMIT 1`,
527
740
  )
@@ -543,7 +756,7 @@ export class HostStateStore {
543
756
  FROM interactive_requests i
544
757
  JOIN interactive_submissions s ON s.request_id = i.request_id
545
758
  JOIN run_queue q ON q.run_id = i.run_id
546
- WHERE i.status IN ('pending', 'presenting') AND s.outcome = 'validating'
759
+ WHERE i.status = 'pending' AND s.outcome = 'validating'
547
760
  AND q.status NOT IN ('done', 'failed', 'cancelled')
548
761
  GROUP BY i.run_id
549
762
  ORDER BY MIN(s.submitted_at), i.run_id`,
@@ -552,21 +765,27 @@ export class HostStateStore {
552
765
  .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
553
766
  }
554
767
 
555
- expiredInteractionRunIds(now: number = Date.now()): string[] {
768
+ expiredInteractionRuns(now: number = Date.now()): ExpiredInteractionRunRow[] {
556
769
  return this.state.connection
557
770
  .prepare(
558
- `SELECT i.run_id AS runId
771
+ `SELECT i.run_id AS runId, i.target_session_id AS targetSessionId
559
772
  FROM interactive_requests i
560
773
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
774
+ JOIN runs r ON r.run_id = i.run_id
561
775
  JOIN run_queue q ON q.run_id = i.run_id
562
- WHERE i.status IN ('pending', 'presenting')
776
+ WHERE i.status = 'pending' AND r.paused = 0
563
777
  AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
564
778
  AND q.status NOT IN ('done', 'failed', 'cancelled')
565
- GROUP BY i.run_id
779
+ AND EXISTS (
780
+ SELECT 1 FROM workflow_messages m
781
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
782
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
783
+ )
784
+ GROUP BY i.run_id, i.target_session_id
566
785
  ORDER BY MIN(a.deadline_at), i.run_id`,
567
786
  )
568
787
  .all(now)
569
- .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
788
+ .filter(isExpiredInteractionRunRow);
570
789
  }
571
790
 
572
791
  timedOutInteraction(runId: string): TimedOutInteractionRow | undefined {
@@ -577,8 +796,13 @@ export class HostStateStore {
577
796
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
578
797
  JOIN runs r ON r.run_id = i.run_id
579
798
  WHERE i.run_id = ? AND i.status = 'cancelled' AND r.status = 'running'
580
- AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
581
- AND a.status IN ('pending', 'running', 'waiting', 'interrupted')
799
+ AND r.paused = 0 AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
800
+ AND a.status IN ('pending', 'running', 'waiting', 'interrupted', 'timed_out')
801
+ AND EXISTS (
802
+ SELECT 1 FROM workflow_messages m
803
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
804
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
805
+ )
582
806
  ORDER BY a.deadline_at, i.request_id LIMIT 1`,
583
807
  )
584
808
  .get(runId, Date.now());
@@ -640,13 +864,14 @@ export class HostStateStore {
640
864
  const changed = this.state.connection
641
865
  .prepare(
642
866
  `UPDATE interactive_requests
643
- SET status = 'settled', presenter_id = NULL,
644
- presentation_claim_expires_at = NULL, accepted_submission_id = ?,
867
+ SET status = 'settled', accepted_submission_id = ?,
645
868
  revision = revision + 1, updated_at = ?, settled_at = ?
646
- WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`,
869
+ WHERE request_id = ? AND revision = ? AND status = 'pending'`,
647
870
  )
648
871
  .run(options.submissionId, now, now, options.requestId, request.revision);
649
872
  if (changed.changes !== 1) throw new Error("Interactive request validation is stale");
873
+ this.workflowMessages.cancelPendingForSource(options.requestId, "step", now);
874
+ this.workflowMessages.cancelPendingForSource(options.requestId, "decision", now);
650
875
  }
651
876
  const settled = this.interactionSubmission(options.requestId, options.submissionId);
652
877
  if (settled === undefined) throw new Error("Interactive submission result is missing");
@@ -669,7 +894,7 @@ export class HostStateStore {
669
894
  return this.state.connection
670
895
  .prepare(
671
896
  `SELECT request_id AS requestId FROM interactive_requests
672
- WHERE target_session_id = ? AND status IN ('pending', 'presenting')
897
+ WHERE target_session_id = ? AND status = 'pending'
673
898
  ORDER BY created_at, request_id`,
674
899
  )
675
900
  .all(sessionId)
@@ -678,60 +903,17 @@ export class HostStateStore {
678
903
  );
679
904
  }
680
905
 
681
- claimInteractionPresentation(options: {
682
- requestId: string;
683
- expectedRevision: number;
684
- presenterId: string;
685
- leaseMs: number;
686
- }): InteractiveRequestRecord {
687
- requireLeaseMs(options.leaseMs);
688
- const now = Date.now();
689
- const changed = this.state.connection
690
- .prepare(
691
- `UPDATE interactive_requests
692
- SET status = 'presenting', presenter_id = ?, presentation_claim_expires_at = ?,
693
- revision = revision + 1, updated_at = ?
694
- WHERE request_id = ? AND revision = ? AND presentation_session_entry_id IS NULL
695
- AND (
696
- status = 'pending'
697
- OR (status = 'presenting' AND presentation_claim_expires_at <= ?)
698
- )`,
699
- )
700
- .run(
701
- options.presenterId,
702
- now + options.leaseMs,
703
- now,
704
- options.requestId,
705
- options.expectedRevision,
706
- now,
707
- );
708
- if (changed.changes !== 1) throw new Error("Interactive request presentation claim conflict");
709
- return this.requireInteractiveRequest(options.requestId);
710
- }
711
-
712
- markInteractionPresented(options: {
713
- requestId: string;
714
- expectedRevision: number;
715
- sessionEntryId: string;
716
- }): InteractiveRequestRecord {
717
- const now = Date.now();
718
- const changed = this.state.connection
906
+ listPendingDecisionInteractions(): InteractiveRequestRecord[] {
907
+ return this.state.connection
719
908
  .prepare(
720
- `UPDATE interactive_requests
721
- SET status = 'presenting', presenter_id = NULL, presentation_claim_expires_at = NULL,
722
- presentation_session_entry_id = ?, revision = revision + 1, updated_at = ?
723
- WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')
724
- AND (presentation_session_entry_id IS NULL OR presentation_session_entry_id = ?)`,
909
+ `SELECT request_id AS requestId FROM interactive_requests
910
+ WHERE kind = 'decision' AND status = 'pending'
911
+ ORDER BY created_at, request_id`,
725
912
  )
726
- .run(
727
- options.sessionEntryId,
728
- now,
729
- options.requestId,
730
- options.expectedRevision,
731
- options.sessionEntryId,
913
+ .all()
914
+ .flatMap((row) =>
915
+ isRequestIdRow(row) ? [this.requireInteractiveRequest(row.requestId)] : [],
732
916
  );
733
- if (changed.changes !== 1) throw new Error("Interactive request presentation conflict");
734
- return this.requireInteractiveRequest(options.requestId);
735
917
  }
736
918
 
737
919
  submitInteraction(options: {
@@ -826,10 +1008,7 @@ export class HostStateStore {
826
1008
  }
827
1009
  }
828
1010
  const request = this.requireInteractiveRequest(options.requestId);
829
- if (
830
- request.revision !== options.expectedRevision ||
831
- !["pending", "presenting"].includes(request.status)
832
- ) {
1011
+ if (request.revision !== options.expectedRevision || request.status !== "pending") {
833
1012
  throw new Error("Interactive request revision conflict");
834
1013
  }
835
1014
  const savedPayloadHash = this.state.putJson(options.payload, now);
@@ -856,12 +1035,13 @@ export class HostStateStore {
856
1035
  this.state.connection
857
1036
  .prepare(
858
1037
  `UPDATE interactive_requests
859
- SET status = 'settled', presenter_id = NULL, presentation_claim_expires_at = NULL,
860
- accepted_submission_id = ?, revision = revision + 1,
1038
+ SET status = 'settled', accepted_submission_id = ?, revision = revision + 1,
861
1039
  updated_at = ?, settled_at = ?
862
- WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`,
1040
+ WHERE request_id = ? AND revision = ? AND status = 'pending'`,
863
1041
  )
864
1042
  .run(options.submissionId, now, now, options.requestId, options.expectedRevision);
1043
+ this.workflowMessages.cancelPendingForSource(options.requestId, "step", now);
1044
+ this.workflowMessages.cancelPendingForSource(options.requestId, "decision", now);
865
1045
  }
866
1046
  return {
867
1047
  interaction: this.requireInteractiveRequest(options.requestId),
@@ -910,9 +1090,7 @@ export class HostStateStore {
910
1090
  .prepare(
911
1091
  `SELECT request_id AS requestId, run_id AS runId, attempt_id AS attemptId,
912
1092
  target_session_id AS targetSessionId, kind, contract_hash AS contractHash,
913
- revision, status, presenter_id AS presenterId,
914
- presentation_claim_expires_at AS presentationClaimExpiresAt,
915
- presentation_session_entry_id AS presentationSessionEntryId,
1093
+ revision, status, unproductive_turn_ends AS unproductiveTurnEnds,
916
1094
  accepted_submission_id AS acceptedSubmissionId, created_at AS createdAt,
917
1095
  updated_at AS updatedAt, settled_at AS settledAt, consumed_at AS consumedAt
918
1096
  FROM interactive_requests WHERE request_id = ?`,
@@ -928,9 +1106,7 @@ export class HostStateStore {
928
1106
  contract: this.state.readJson(row.contractHash),
929
1107
  revision: row.revision,
930
1108
  status: row.status,
931
- presenterId: row.presenterId,
932
- presentationClaimExpiresAt: iso(row.presentationClaimExpiresAt),
933
- presentationSessionEntryId: row.presentationSessionEntryId,
1109
+ unproductiveTurnEnds: row.unproductiveTurnEnds,
934
1110
  acceptedSubmissionId: row.acceptedSubmissionId,
935
1111
  createdAt: new Date(row.createdAt).toISOString(),
936
1112
  updatedAt: new Date(row.updatedAt).toISOString(),
@@ -939,6 +1115,23 @@ export class HostStateStore {
939
1115
  };
940
1116
  }
941
1117
 
1118
+ private shiftInteractionTimeout(
1119
+ row: Pick<InteractionTimeoutRow, "attemptId" | "startedAt" | "deadlineAt">,
1120
+ idleMs: number,
1121
+ now: number,
1122
+ ): void {
1123
+ if (idleMs === 0 || row.deadlineAt === null) return;
1124
+ const changed = this.state.connection
1125
+ .prepare(
1126
+ `UPDATE node_attempts
1127
+ SET started_at = CASE WHEN started_at IS NULL THEN NULL ELSE started_at + ? END,
1128
+ deadline_at = deadline_at + ?, updated_at = ?
1129
+ WHERE attempt_id = ? AND deadline_at = ?`,
1130
+ )
1131
+ .run(idleMs, idleMs, now, row.attemptId, row.deadlineAt);
1132
+ if (changed.changes !== 1) throw new Error("Workflow interaction timeout changed concurrently");
1133
+ }
1134
+
942
1135
  private requireInteractiveRequest(requestId: string): InteractiveRequestRecord {
943
1136
  const request = this.interactiveRequest(requestId);
944
1137
  if (request === undefined) throw new Error(`Interactive request not found: ${requestId}`);
@@ -1022,6 +1215,23 @@ type TimedOutInteractionRow = {
1022
1215
  attemptId: string;
1023
1216
  nodeId: string;
1024
1217
  };
1218
+ type InteractionTimeoutRow = {
1219
+ attemptId: string;
1220
+ createdAt: number;
1221
+ startedAt: number | null;
1222
+ deadlineAt: number | null;
1223
+ previousTurnEndedAt: number | null;
1224
+ };
1225
+ type InactiveInteractionTimeoutRow = {
1226
+ attemptId: string;
1227
+ status: "pending" | "running" | "waiting" | "interrupted";
1228
+ startedAt: number | null;
1229
+ deadlineAt: number | null;
1230
+ updatedAt: number;
1231
+ turnStartedAt: number;
1232
+ };
1233
+ type ExpiredInteractionRunRow = { runId: string; targetSessionId: string };
1234
+ type PausedAtRow = { pausedAt: number };
1025
1235
  type InteractiveRequestRow = {
1026
1236
  requestId: string;
1027
1237
  runId: string;
@@ -1031,9 +1241,7 @@ type InteractiveRequestRow = {
1031
1241
  contractHash: Buffer;
1032
1242
  revision: number;
1033
1243
  status: InteractiveRequestRecord["status"];
1034
- presenterId: string | null;
1035
- presentationClaimExpiresAt: number | null;
1036
- presentationSessionEntryId: string | null;
1244
+ unproductiveTurnEnds: number;
1037
1245
  acceptedSubmissionId: string | null;
1038
1246
  createdAt: number;
1039
1247
  updatedAt: number;
@@ -1113,6 +1321,39 @@ function isTimedOutInteractionRow(value: unknown): value is TimedOutInteractionR
1113
1321
  );
1114
1322
  }
1115
1323
 
1324
+ function isInteractionTimeoutRow(value: unknown): value is InteractionTimeoutRow {
1325
+ return (
1326
+ isRecord(value) &&
1327
+ typeof value.attemptId === "string" &&
1328
+ typeof value.createdAt === "number" &&
1329
+ nullableNumber(value.startedAt) &&
1330
+ nullableNumber(value.deadlineAt) &&
1331
+ nullableNumber(value.previousTurnEndedAt)
1332
+ );
1333
+ }
1334
+
1335
+ function isInactiveInteractionTimeoutRow(value: unknown): value is InactiveInteractionTimeoutRow {
1336
+ return (
1337
+ isRecord(value) &&
1338
+ typeof value.attemptId === "string" &&
1339
+ ["pending", "running", "waiting", "interrupted"].includes(value.status as string) &&
1340
+ nullableNumber(value.startedAt) &&
1341
+ nullableNumber(value.deadlineAt) &&
1342
+ typeof value.updatedAt === "number" &&
1343
+ typeof value.turnStartedAt === "number"
1344
+ );
1345
+ }
1346
+
1347
+ function isExpiredInteractionRunRow(value: unknown): value is ExpiredInteractionRunRow {
1348
+ return (
1349
+ isRecord(value) && typeof value.runId === "string" && typeof value.targetSessionId === "string"
1350
+ );
1351
+ }
1352
+
1353
+ function isPausedAtRow(value: unknown): value is PausedAtRow {
1354
+ return isRecord(value) && typeof value.pausedAt === "number";
1355
+ }
1356
+
1116
1357
  function isSubmissionRow(value: unknown): value is SubmissionRow {
1117
1358
  return (
1118
1359
  isRecord(value) &&
@@ -1150,10 +1391,8 @@ function isInteractiveRequestRow(value: unknown): value is InteractiveRequestRow
1150
1391
  ["agent", "assistant", "decision"].includes(value.kind as string) &&
1151
1392
  Buffer.isBuffer(value.contractHash) &&
1152
1393
  typeof value.revision === "number" &&
1153
- ["pending", "presenting", "settled", "cancelled"].includes(value.status as string) &&
1154
- nullableString(value.presenterId) &&
1155
- nullableNumber(value.presentationClaimExpiresAt) &&
1156
- nullableString(value.presentationSessionEntryId) &&
1394
+ ["pending", "settled", "cancelled"].includes(value.status as string) &&
1395
+ typeof value.unproductiveTurnEnds === "number" &&
1157
1396
  nullableString(value.acceptedSubmissionId) &&
1158
1397
  typeof value.createdAt === "number" &&
1159
1398
  typeof value.updatedAt === "number" &&