@bastani/atomic 0.9.13-alpha.4 → 0.9.14-alpha.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 (101) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/builtin/i-have-adhd/CHANGELOG.md +13 -0
  3. package/dist/builtin/i-have-adhd/package.json +1 -1
  4. package/dist/builtin/intercom/CHANGELOG.md +26 -0
  5. package/dist/builtin/intercom/package.json +1 -1
  6. package/dist/builtin/mcp/CHANGELOG.md +15 -0
  7. package/dist/builtin/mcp/package.json +1 -1
  8. package/dist/builtin/subagents/CHANGELOG.md +41 -0
  9. package/dist/builtin/subagents/package.json +1 -1
  10. package/dist/builtin/web-access/CHANGELOG.md +14 -0
  11. package/dist/builtin/web-access/package.json +1 -1
  12. package/dist/builtin/workflows/CHANGELOG.md +58 -0
  13. package/dist/builtin/workflows/README.md +9 -0
  14. package/dist/builtin/workflows/builtin/adversarial-verification.ts +4 -0
  15. package/dist/builtin/workflows/builtin/classify-and-act.ts +4 -0
  16. package/dist/builtin/workflows/builtin/fan-out-and-synthesize.ts +4 -0
  17. package/dist/builtin/workflows/builtin/generate-and-filter.ts +4 -0
  18. package/dist/builtin/workflows/builtin/goal.ts +4 -0
  19. package/dist/builtin/workflows/builtin/loop-until-done.ts +4 -0
  20. package/dist/builtin/workflows/builtin/open-claude-design.ts +4 -0
  21. package/dist/builtin/workflows/builtin/ralph.ts +4 -0
  22. package/dist/builtin/workflows/builtin/tournament.ts +4 -0
  23. package/dist/builtin/workflows/package.json +1 -1
  24. package/dist/builtin/workflows/src/authoring/workflow.ts +9 -0
  25. package/dist/builtin/workflows/src/authoring.d.ts +3 -0
  26. package/dist/builtin/workflows/src/durable/completed-catalog.ts +13 -2
  27. package/dist/builtin/workflows/src/durable/workflow-heartbeat-anchor.ts +153 -0
  28. package/dist/builtin/workflows/src/engine/run-durable-stage-session.ts +28 -0
  29. package/dist/builtin/workflows/src/engine/run.ts +1 -0
  30. package/dist/builtin/workflows/src/extension/extension-runtime-state.ts +138 -0
  31. package/dist/builtin/workflows/src/extension/index.bundle.mjs +766 -4
  32. package/dist/builtin/workflows/src/extension/workflow-heartbeat-delivery.ts +197 -0
  33. package/dist/builtin/workflows/src/extension/workflow-heartbeat-notice.ts +115 -0
  34. package/dist/builtin/workflows/src/extension/workflow-heartbeat-scheduler.ts +962 -0
  35. package/dist/builtin/workflows/src/sdk-surface.ts +9 -0
  36. package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +1 -0
  37. package/dist/builtin/workflows/src/shared/authoring-contract-ui.ts +1 -0
  38. package/dist/builtin/workflows/src/shared/persistence-restore.ts +12 -0
  39. package/dist/builtin/workflows/src/shared/workflow-authoring-types.d.ts +1 -0
  40. package/dist/builtin/workflows/src/shared/workflow-authoring-types.ts +1 -0
  41. package/dist/builtin/workflows/src/shared/workflow-heartbeat-contract.d.ts +21 -0
  42. package/dist/builtin/workflows/src/shared/workflow-heartbeat-contract.ts +25 -0
  43. package/dist/core/extensions/index.d.ts +1 -0
  44. package/dist/core/extensions/index.d.ts.map +1 -1
  45. package/dist/core/extensions/index.js +1 -0
  46. package/dist/core/extensions/index.js.map +1 -1
  47. package/dist/core/extensions/ui-types.d.ts +26 -0
  48. package/dist/core/extensions/ui-types.d.ts.map +1 -1
  49. package/dist/core/extensions/ui-types.js +17 -1
  50. package/dist/core/extensions/ui-types.js.map +1 -1
  51. package/dist/core/tools/ask-user-question/ask-user-question.d.ts +21 -0
  52. package/dist/core/tools/ask-user-question/ask-user-question.d.ts.map +1 -1
  53. package/dist/core/tools/ask-user-question/ask-user-question.js +34 -9
  54. package/dist/core/tools/ask-user-question/ask-user-question.js.map +1 -1
  55. package/dist/core/tools/ask-user-question/view/components/multi-select-view.d.ts.map +1 -1
  56. package/dist/core/tools/ask-user-question/view/components/multi-select-view.js +5 -2
  57. package/dist/core/tools/ask-user-question/view/components/multi-select-view.js.map +1 -1
  58. package/dist/core/tools/ask-user-question/view/components/submit-picker.d.ts.map +1 -1
  59. package/dist/core/tools/ask-user-question/view/components/submit-picker.js +3 -1
  60. package/dist/core/tools/ask-user-question/view/components/submit-picker.js.map +1 -1
  61. package/dist/core/tools/ask-user-question/view/components/wrapping-select.d.ts.map +1 -1
  62. package/dist/core/tools/ask-user-question/view/components/wrapping-select.js +8 -1
  63. package/dist/core/tools/ask-user-question/view/components/wrapping-select.js.map +1 -1
  64. package/dist/index-extensions.d.ts +1 -1
  65. package/dist/index-extensions.d.ts.map +1 -1
  66. package/dist/index-extensions.js +1 -1
  67. package/dist/index-extensions.js.map +1 -1
  68. package/dist/modes/interactive/components/reserved-bottom-overlay.d.ts +95 -0
  69. package/dist/modes/interactive/components/reserved-bottom-overlay.d.ts.map +1 -0
  70. package/dist/modes/interactive/components/reserved-bottom-overlay.js +234 -0
  71. package/dist/modes/interactive/components/reserved-bottom-overlay.js.map +1 -0
  72. package/dist/modes/interactive/interactive-extension-custom-ui.js +94 -6
  73. package/dist/modes/interactive/interactive-extension-custom-ui.js.map +1 -1
  74. package/dist/modes/interactive/interactive-mode-base.d.ts +3 -0
  75. package/dist/modes/interactive/interactive-mode-base.d.ts.map +1 -1
  76. package/dist/modes/interactive/interactive-mode-base.js +5 -1
  77. package/dist/modes/interactive/interactive-mode-base.js.map +1 -1
  78. package/dist/modes/interactive/interactive-mode-surface.d.ts +2 -0
  79. package/dist/modes/interactive/interactive-mode-surface.d.ts.map +1 -1
  80. package/dist/modes/interactive/interactive-mode-surface.js.map +1 -1
  81. package/dist/modes/interactive/interactive-tui.d.ts +3 -0
  82. package/dist/modes/interactive/interactive-tui.d.ts.map +1 -1
  83. package/dist/modes/interactive/interactive-tui.js +14 -0
  84. package/dist/modes/interactive/interactive-tui.js.map +1 -1
  85. package/dist/modes/interactive-engine/engine-custom-ui.d.ts +2 -0
  86. package/dist/modes/interactive-engine/engine-custom-ui.d.ts.map +1 -1
  87. package/dist/modes/interactive-engine/engine-custom-ui.js +1 -0
  88. package/dist/modes/interactive-engine/engine-custom-ui.js.map +1 -1
  89. package/dist/modes/interactive-engine/protocol.d.ts +1 -0
  90. package/dist/modes/interactive-engine/protocol.d.ts.map +1 -1
  91. package/dist/modes/interactive-engine/protocol.js +1 -0
  92. package/dist/modes/interactive-engine/protocol.js.map +1 -1
  93. package/dist/modes/interactive-engine/remote-component.d.ts +3 -1
  94. package/dist/modes/interactive-engine/remote-component.d.ts.map +1 -1
  95. package/dist/modes/interactive-engine/remote-component.js +3 -2
  96. package/dist/modes/interactive-engine/remote-component.js.map +1 -1
  97. package/docs/extensions.md +27 -0
  98. package/docs/keybindings.md +1 -0
  99. package/docs/workflows.md +34 -0
  100. package/npm-shrinkwrap.json +29 -29
  101. package/package.json +2 -2
@@ -6,6 +6,10 @@ import { runTournament } from "./tournament-runner.js";
6
6
  export default workflow({
7
7
  name: "tournament",
8
8
  description: "Run several independent whole-task attempts through a balanced pairwise judging bracket and return an auditable winner.",
9
+ // The 15-minute default, stated rather than inherited: this is a per-workflow
10
+ // product decision, so a future change to the global default must not silently
11
+ // re-cadence a long autonomous run.
12
+ heartbeatIntervalMinutes: 15,
9
13
  inputs: {
10
14
  prompt: Type.String({ description: "Task every competing agent must attempt independently." }),
11
15
  num_attempts: Type.Integer({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/workflows",
3
- "version": "0.9.13-alpha.4",
3
+ "version": "0.9.14-alpha.1",
4
4
  "private": true,
5
5
  "description": "Atomic extension for multi-stage workflow authoring and execution.",
6
6
  "contributors": [
@@ -17,6 +17,7 @@ import type {
17
17
  WorkflowOutputsFromSchemas,
18
18
  WorkflowProvidedInputsFromSchemas,
19
19
  } from "../shared/workflow-authoring-types.js";
20
+ import { DEFAULT_WORKFLOW_HEARTBEAT_INTERVAL_MINUTES } from "../shared/workflow-heartbeat-contract.js";
20
21
  import { normalizeWorkflowName } from "../workflows/identity.js";
21
22
 
22
23
  export type {
@@ -158,6 +159,13 @@ export function workflow<
158
159
  ) {
159
160
  throw new TypeError("workflow: inputs must be a schema map");
160
161
  }
162
+ const heartbeatIntervalMinutes =
163
+ spec.heartbeatIntervalMinutes === undefined
164
+ ? DEFAULT_WORKFLOW_HEARTBEAT_INTERVAL_MINUTES
165
+ : spec.heartbeatIntervalMinutes;
166
+ if (!Number.isFinite(heartbeatIntervalMinutes) || heartbeatIntervalMinutes < 0) {
167
+ throw new TypeError("workflow: heartbeatIntervalMinutes must be a non-negative finite number");
168
+ }
161
169
 
162
170
  const name = resolveWorkflowName(spec.name);
163
171
  const normalizedName = normalizeWorkflowName(name);
@@ -174,6 +182,7 @@ export function workflow<
174
182
  normalizedName,
175
183
  description: spec.description,
176
184
  ...(spec.autoAttach === true ? { autoAttach: true } : {}),
185
+ heartbeatIntervalMinutes,
177
186
  inputs: frozenInputs,
178
187
  outputs: frozenOutputs,
179
188
  ...(inputBindings !== undefined ? { inputBindings } : {}),
@@ -4,6 +4,7 @@ export type { AgentSessionAdapter, CompleteAdapter, CompleteStageOpts, GitWorktr
4
4
  import type * as AuthoringContract from "./shared/authoring-contract.js";
5
5
  import type { AuthoredWorkflowSpec as SharedAuthoredWorkflowSpec, WorkflowInputsFromSchemas, WorkflowOutputsFromSchemas, WorkflowProvidedInputsFromSchemas } from "./shared/workflow-authoring-types.js";
6
6
  export type { WorkflowInputsFromSchemas, WorkflowOutputsFromSchemas, WorkflowProvidedInputsFromSchemas, } from "./shared/workflow-authoring-types.js";
7
+ export type { WorkflowHeartbeatEvent, WorkflowHeartbeatEventDetails, WorkflowHeartbeatIdentity, } from "./shared/workflow-heartbeat-contract.js";
7
8
  import type { GitWorktreeSetupOptions, GitWorktreeSetupResult, ResolvedInputs, RunResult, RunStatus, StageSnapshot, WorkflowDefinition as WorkflowContractDefinition, WorkflowExecutionPolicy, WorkflowInputSchemaMap, WorkflowInputValues, WorkflowOutputSchemaMap, WorkflowOutputValues, WorkflowSerializableObject } from "./shared/authoring-contract.js";
8
9
  declare const workflowDefinitionBrand: unique symbol;
9
10
  type WorkflowDefinitionBrand = {
@@ -55,6 +56,8 @@ export declare function workflow<const TInputs extends WorkflowInputSchemaMap =
55
56
  export declare function keepContext(text: string): string;
56
57
  export declare const KEEP_CONTEXT_OPEN_TAG: string;
57
58
  export declare const KEEP_CONTEXT_CLOSE_TAG: string;
59
+ export declare const DEFAULT_WORKFLOW_HEARTBEAT_INTERVAL_MINUTES: 15;
60
+ export declare const WORKFLOW_HEARTBEAT_CUSTOM_TYPE: "workflows:workflow-heartbeat";
58
61
  export declare function createRegistry<TDefinitions extends readonly AnyWorkflowDefinition[] = readonly AnyWorkflowDefinition[]>(initial?: TDefinitions): WorkflowRegistry;
59
62
  export declare function normalizeWorkflowName(name: string): string;
60
63
  export declare function workflowNamesEqual(a: string, b: string): boolean;
@@ -23,6 +23,17 @@ import {
23
23
  import { isDurableWorkflowResumable } from "./resume-eligibility.js";
24
24
  import { resolveDurableEntry } from "./resume-runtime.js";
25
25
  import { priorRunElapsedMs, RUN_TIMING_CHECKPOINT_NAME } from "./run-timing.js";
26
+ import { WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME } from "./workflow-heartbeat-anchor.js";
27
+
28
+ /**
29
+ * Reserved run-level tool checkpoints that carry no graph node. They round-trip
30
+ * through the durable envelope like any tool checkpoint, so reconstruction has
31
+ * to skip them or they surface as phantom cached tool nodes.
32
+ */
33
+ function isReservedRunCheckpointHash(argsHash: string): boolean {
34
+ return argsHash === RUN_TIMING_CHECKPOINT_NAME || argsHash === WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME;
35
+ }
36
+
26
37
  import {
27
38
  directChildTopologyError,
28
39
  groupByDurableStageKey,
@@ -218,7 +229,7 @@ function checkpointDrafts(checkpoints: readonly DurableCheckpoint[]): Reconstruc
218
229
  stageByReplayKey.set(checkpoint.replayKey, mergeStageDraft(existing, checkpoint, sequence));
219
230
  return;
220
231
  }
221
- if (checkpoint.kind !== "tool" || checkpoint.argsHash === RUN_TIMING_CHECKPOINT_NAME) return;
232
+ if (checkpoint.kind !== "tool" || isReservedRunCheckpointHash(checkpoint.argsHash)) return;
222
233
  if (!firstToolSequenceByHash.has(checkpoint.argsHash)) {
223
234
  firstToolSequenceByHash.set(checkpoint.argsHash, sequence);
224
235
  }
@@ -390,7 +401,7 @@ function runSnapshotsFromCheckpoints(
390
401
  const supportedNonGraphState = checkpoints.every(
391
402
  (checkpoint) =>
392
403
  checkpoint.kind === "ui" ||
393
- (checkpoint.kind === "tool" && checkpoint.argsHash === RUN_TIMING_CHECKPOINT_NAME),
404
+ (checkpoint.kind === "tool" && isReservedRunCheckpointHash(checkpoint.argsHash)),
394
405
  );
395
406
  if (policy.failClosed && supportedNonGraphState) {
396
407
  return [emptyGraphRun(rootRunId, rootRunName, fallbackCompletedAt)];
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Durable workflow-heartbeat cadence anchor.
3
+ *
4
+ * The heartbeat cadence is `anchorAt + n × interval`. Every input to that is
5
+ * already persisted except one: the *original* run start time. A durable resume
6
+ * re-dispatches under the original workflow id but mints a fresh
7
+ * `RunSnapshot.startedAt` (`engine/run.ts`), and that fresh value is passed to
8
+ * `registerWorkflow` as `createdAt`, which the backend overwrites
9
+ * unconditionally (`durable/backend.ts`). Checkpoints are the only per-run state
10
+ * that survives re-registration, so this record is the sole surviving carrier of
11
+ * the original start — which is what keeps a resumed run on its original
12
+ * cadence rather than starting a fresh series.
13
+ *
14
+ * Storage shape: a reserved tool-kind checkpoint (name/argsHash
15
+ * `workflow-heartbeat-anchor`), following `run-timing.ts`. Its `checkpointId` is
16
+ * that same constant with no suffix, so `recordCheckpoint`'s duplicate-key early
17
+ * return makes the record **write-once and exactly one row per run** rather than
18
+ * one row per boundary.
19
+ *
20
+ * Three properties this shape is chosen for:
21
+ *
22
+ * - **It cannot manufacture resumability.** `recordCheckpoint` sets
23
+ * `completedCheckpoints = checkpoints.size`, and `isDurableWorkflowResumable`
24
+ * gates a running or paused run on `completedCheckpoints > 0`. Writing for a
25
+ * run with no durable progress of its own would make it look resumable, so the
26
+ * write is skipped until the run has at least one other checkpoint — the same
27
+ * guard, and the same reason, as `run-timing.ts`.
28
+ * - **It cannot walk liveness backwards.** `recordCheckpoint` copies
29
+ * `completedAt` onto the handle as `updatedAt`, which the foreign-liveness
30
+ * window reads, so `completedAt` is the write time and never a cadence
31
+ * boundary.
32
+ * - **It cannot move a boundary forward.** It is read as
33
+ * `min(run.startedAt, anchorAt)`, so it can only restore the original anchor,
34
+ * never advance it. Recovery still floors at `now`, so no missed boundary is
35
+ * ever replayed.
36
+ *
37
+ * The record has no per-checkpoint deletion — `DurableWorkflowBackend` removes
38
+ * whole workflows only — so terminal cleanup (issue #1975) invalidates it on the
39
+ * read side instead: the scheduler neither reads nor writes an anchor for a run
40
+ * it observes as terminal, and drops the in-memory memo of it. A leftover row is
41
+ * therefore inert, and cannot put a finished run back on a cadence.
42
+ *
43
+ * cross-ref: packages/workflows/src/extension/workflow-heartbeat-scheduler.ts
44
+ */
45
+
46
+ import type { DurableWorkflowBackend } from "./backend.js";
47
+ import type { DurableToolCheckpoint } from "./types.js";
48
+
49
+ /** Reserved checkpoint name, args-hash, AND checkpoint id for cadence anchors. */
50
+ export const WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME = "workflow-heartbeat-anchor";
51
+
52
+ /**
53
+ * What a run launched with: its original start time, and the cadence its own
54
+ * definition declared.
55
+ *
56
+ * `intervalMinutes` is absent on records written before it was added, so a
57
+ * reader must treat that as "unknown" and fall back to the live definition —
58
+ * never as `0`.
59
+ */
60
+ export interface WorkflowHeartbeatAnchorRecord {
61
+ readonly anchorAt: number;
62
+ readonly intervalMinutes?: number;
63
+ }
64
+
65
+ /**
66
+ * The stored checkpoint payload, before validation.
67
+ *
68
+ * `getToolOutput` returns whatever a previous process wrote, so the fields are
69
+ * declared optional and validated below rather than trusted. Naming the shape
70
+ * keeps the decode free of inline casts while leaving the trust boundary
71
+ * explicit.
72
+ */
73
+ interface PersistedAnchorOutput {
74
+ readonly anchorAt?: number;
75
+ readonly intervalMinutes?: number;
76
+ }
77
+
78
+ function asPersistedAnchorOutput(output: unknown): PersistedAnchorOutput | undefined {
79
+ if (typeof output !== "object" || output === null || Array.isArray(output)) return undefined;
80
+ const { anchorAt, intervalMinutes } = output as Partial<Record<keyof PersistedAnchorOutput, unknown>>;
81
+ return {
82
+ ...(typeof anchorAt === "number" ? { anchorAt } : {}),
83
+ ...(typeof intervalMinutes === "number" ? { intervalMinutes } : {}),
84
+ };
85
+ }
86
+
87
+ /** The persisted launch anchor for a run, or undefined when absent or malformed. */
88
+ export function readWorkflowHeartbeatAnchor(
89
+ backend: DurableWorkflowBackend,
90
+ workflowId: string,
91
+ ): WorkflowHeartbeatAnchorRecord | undefined {
92
+ const record = asPersistedAnchorOutput(backend.getToolOutput(workflowId, WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME));
93
+ if (record === undefined) return undefined;
94
+ const { anchorAt, intervalMinutes } = record;
95
+ if (anchorAt === undefined || !Number.isFinite(anchorAt)) return undefined;
96
+ // Only a positive finite cadence is carried: a disabled run never writes a
97
+ // record at all, so a non-positive value here is corrupt rather than meaningful.
98
+ return intervalMinutes !== undefined && Number.isFinite(intervalMinutes) && intervalMinutes > 0
99
+ ? { anchorAt, intervalMinutes }
100
+ : { anchorAt };
101
+ }
102
+
103
+ /**
104
+ * Persist what a run launched with, once.
105
+ *
106
+ * Uses `recordAdditiveCheckpointBestEffort` rather than the synchronous
107
+ * `recordCheckpoint`, because the DBOS backend updates its in-memory mirror
108
+ * *before* the real write is queued: a synchronous read-back therefore proves
109
+ * only that the mirror holds the row, and would let a rejected storage write be
110
+ * remembered as a success that is never retried. The best-effort path awaits
111
+ * `recordStepOutput` and updates the mirror only after it resolves, and turns a
112
+ * storage rejection into `false` instead of a fatal flush error.
113
+ *
114
+ * Returns `true` only when the record is durably written *and* readable — the
115
+ * backend silently drops a checkpoint for a workflow it has never registered,
116
+ * so the read-back stays as well.
117
+ */
118
+ export async function recordWorkflowHeartbeatAnchor(
119
+ backend: DurableWorkflowBackend,
120
+ record: {
121
+ readonly runId: string;
122
+ readonly anchorAt: number;
123
+ readonly intervalMinutes: number;
124
+ readonly now: number;
125
+ },
126
+ ): Promise<boolean> {
127
+ if (!Number.isFinite(record.anchorAt)) return false;
128
+ // A run launched with heartbeats disabled writes no record at all, so this
129
+ // path is never reached for a non-positive cadence. Guarding it here keeps
130
+ // that invariant local to the writer as well as to its caller.
131
+ if (!Number.isFinite(record.intervalMinutes) || record.intervalMinutes <= 0) return false;
132
+ const existing = readWorkflowHeartbeatAnchor(backend, record.runId);
133
+ // Write-once: the first record stands. A later write cannot move the anchor
134
+ // forward, and it cannot retro-fit an edited cadence onto a run in flight.
135
+ if (existing !== undefined) return true;
136
+ // A record for a run with no durable progress of its own would make that run
137
+ // look resumable; the anchor is worth nothing on a run that cannot resume.
138
+ if (backend.listCheckpoints(record.runId).length === 0) return false;
139
+ const checkpoint: DurableToolCheckpoint = {
140
+ kind: "tool",
141
+ workflowId: record.runId,
142
+ checkpointId: WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME,
143
+ name: WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME,
144
+ argsHash: WORKFLOW_HEARTBEAT_ANCHOR_CHECKPOINT_NAME,
145
+ output: { anchorAt: record.anchorAt, intervalMinutes: record.intervalMinutes },
146
+ // Write time, never the boundary: this lands on the handle as `updatedAt`,
147
+ // which the foreign-liveness window reads.
148
+ completedAt: record.now,
149
+ };
150
+ const stored = await backend.recordAdditiveCheckpointBestEffort(checkpoint);
151
+ if (!stored) return false;
152
+ return readWorkflowHeartbeatAnchor(backend, record.runId) !== undefined;
153
+ }
@@ -1,5 +1,6 @@
1
1
  import { recordRunTimingCheckpointAsync } from "../durable/run-timing.js";
2
2
  import { type DurableStageDeps, recordStageSessionCheckpoint } from "../durable/stage-primitive.js";
3
+ import { recordWorkflowHeartbeatAnchor } from "../durable/workflow-heartbeat-anchor.js";
3
4
  import type { StageSessionCheckpointOptions } from "../runs/foreground/executor-types.js";
4
5
  import type { RunSnapshot, StageSnapshot } from "../shared/store-types.js";
5
6
 
@@ -18,6 +19,8 @@ export interface DurableStageSessionRecorderInput {
18
19
  * only tracked for the root workflow.
19
20
  */
20
21
  readonly runSnapshot?: RunSnapshot;
22
+ /** Cadence declared by the live definition that minted `runSnapshot`. */
23
+ readonly heartbeatIntervalMinutes: number;
21
24
  }
22
25
 
23
26
  export function createDurableStageSessionRecorder(
@@ -30,6 +33,31 @@ export function createDurableStageSessionRecorder(
30
33
  await recordRunTimingCheckpointAsync(input.deps.backend, input.runSnapshot, {
31
34
  debounce: options?.forceDurable !== true,
32
35
  });
36
+ // Any checkpoint above can be the first one that makes the run
37
+ // resumable, and the scheduler's own launch-record write is
38
+ // asynchronous and best-effort: it is guarded against runs with no
39
+ // durable progress, so it can only be issued *after* resumability
40
+ // already exists. A process that exits inside that window leaves a
41
+ // resumable run with no record of what it launched with, and the next
42
+ // process then reads a freshly minted `startedAt` and whatever cadence
43
+ // the definition currently declares — shifting both phase and cadence
44
+ // under a run already in flight.
45
+ //
46
+ // Awaiting the write here closes that window: the record is durable
47
+ // before this checkpoint is acknowledged to its caller. It is
48
+ // write-once and returns early once present, so later passes cost one
49
+ // mirror read. A forced pause/quit capture additionally has no further
50
+ // active schedule pass to retry in, which is why this must not be
51
+ // limited to the ordinary path either.
52
+ const intervalMinutes = input.heartbeatIntervalMinutes;
53
+ if (Number.isFinite(intervalMinutes * 60_000)) {
54
+ await recordWorkflowHeartbeatAnchor(input.deps.backend, {
55
+ runId: input.runId,
56
+ anchorAt: input.runSnapshot.startedAt,
57
+ intervalMinutes,
58
+ now: input.deps.now?.() ?? Date.now(),
59
+ });
60
+ }
33
61
  }
34
62
  }
35
63
  await input.onStageSession?.(stageRunId, snapshot, options);
@@ -296,6 +296,7 @@ export async function run<TInputs extends WorkflowInputValues, TRunInputs extend
296
296
  deps: durableStageDeps,
297
297
  onStageSession: opts.onStageSession,
298
298
  ...(opts.parentRun === undefined ? { runSnapshot } : {}),
299
+ heartbeatIntervalMinutes: def.heartbeatIntervalMinutes,
299
300
  });
300
301
  const stageOptions: EngineStageRuntimeOptions = {
301
302
  continuation: opts.continuation,
@@ -1,3 +1,5 @@
1
+ import { getDurableBackend } from "../durable/factory.js";
2
+ import { readWorkflowHeartbeatAnchor, recordWorkflowHeartbeatAnchor } from "../durable/workflow-heartbeat-anchor.js";
1
3
  import { cancellationRegistry } from "../runs/background/cancellation-registry.js";
2
4
  import type { StageAdapters } from "../runs/foreground/stage-runner.js";
3
5
  import type { SessionManager } from "../shared/persistence-restore.js";
@@ -11,6 +13,7 @@ import type {
11
13
  WorkflowPersistencePort,
12
14
  WorkflowRuntimeConfig,
13
15
  } from "../shared/types.js";
16
+ import type { WorkflowHeartbeatIdentity } from "../shared/workflow-heartbeat-contract.js";
14
17
  import {
15
18
  type ConfigLoadResult,
16
19
  loadWorkflowConfig,
@@ -35,11 +38,57 @@ import {
35
38
  import type { ExtensionAPI, PiModelContext } from "./public-types.js";
36
39
  import { createExtensionRuntime, type ExtensionRuntime } from "./runtime.js";
37
40
  import { createStatusWriter, type StatusWriter } from "./status-writer.js";
41
+ import { registerWorkflowHeartbeatRenderer } from "./workflow-heartbeat-notice.js";
42
+ import {
43
+ createWorkflowHeartbeatSchedulerState,
44
+ installWorkflowHeartbeatScheduler,
45
+ isWorkflowHeartbeatTerminalRun,
46
+ resetWorkflowHeartbeatSchedulerState,
47
+ type WorkflowHeartbeatAnchorStore,
48
+ type WorkflowHeartbeatScheduler,
49
+ workflowHeartbeatConsumedIdentity,
50
+ workflowHeartbeatContextInvalidation,
51
+ } from "./workflow-heartbeat-scheduler.js";
38
52
  import { workflowModelCatalogFromContext } from "./workflow-model-catalog.js";
39
53
  import { makeMcpPort, makePersistencePort } from "./workflow-ports.js";
40
54
  import { createWorkflowReloadCoordinator } from "./workflow-reload-coordinator.js";
41
55
  import { type WorkflowReloadReport, workflowReloadDiagnostics } from "./workflow-reload-report.js";
42
56
 
57
+ /**
58
+ * Best-effort durable cadence-anchor store for workflow heartbeats.
59
+ *
60
+ * `getDurableBackend()` throws `DbosNotReadyError` until a backend exists, and
61
+ * the scheduler installs at `session_start`, before any workflow has run — so
62
+ * both sides swallow failure and fall back to the run's own `startedAt`.
63
+ */
64
+ function durableWorkflowHeartbeatAnchorStore(): WorkflowHeartbeatAnchorStore {
65
+ return {
66
+ readAnchorAt(runId) {
67
+ try {
68
+ return readWorkflowHeartbeatAnchor(getDurableBackend(), runId);
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ },
73
+ async recordAnchorAt(runId, record) {
74
+ try {
75
+ return await recordWorkflowHeartbeatAnchor(getDurableBackend(), {
76
+ runId,
77
+ anchorAt: record.anchorAt,
78
+ // The scheduler only records a positive cadence; a run launched
79
+ // disabled never reaches this seam.
80
+ intervalMinutes: record.intervalMinutes ?? 0,
81
+ now: Date.now(),
82
+ });
83
+ } catch {
84
+ // A backend that is not ready must not break a background pass; the
85
+ // caller retries on the next schedule pass.
86
+ return false;
87
+ }
88
+ },
89
+ };
90
+ }
91
+
43
92
  export interface WorkflowExtensionRuntimeState {
44
93
  persistenceRef: { current: WorkflowPersistencePort | undefined };
45
94
  mcpPort: WorkflowMcpPort | undefined;
@@ -48,6 +97,7 @@ export interface WorkflowExtensionRuntimeState {
48
97
  discoveryRef: { current: DiscoveryResult | null };
49
98
  lifecycleNotificationState: ReturnType<typeof createWorkflowLifecycleNotificationState>;
50
99
  hilAnswerNotificationState: ReturnType<typeof createWorkflowHilAnswerNotificationState>;
100
+ workflowHeartbeatSchedulerState: ReturnType<typeof createWorkflowHeartbeatSchedulerState>;
51
101
  /** Seed lifecycle notification state before completed historical snapshots are inserted. */
52
102
  beforeRestoreCompleted(snapshots: readonly RunSnapshot[]): void;
53
103
  runtimeForContext(ctx?: PiModelContext): ExtensionRuntime;
@@ -83,10 +133,12 @@ export function createWorkflowExtensionRuntimeState(
83
133
  let statusWriterRef: StatusWriter = createStatusWriter(store, runtimeConfigRef.current);
84
134
  let lifecycleNotificationsUnsubscribe: (() => void) | null = null;
85
135
  let hilAnswerNotificationsUnsubscribe: (() => void) | null = null;
136
+ let workflowHeartbeatScheduler: WorkflowHeartbeatScheduler | null = null;
86
137
  let notificationsActive = false;
87
138
  let notificationGeneration = 0;
88
139
  const lifecycleNotificationState = createWorkflowLifecycleNotificationState();
89
140
  const hilAnswerNotificationState = createWorkflowHilAnswerNotificationState();
141
+ const workflowHeartbeatSchedulerState = createWorkflowHeartbeatSchedulerState();
90
142
  const beforeRestoreCompleted = (snapshots: readonly RunSnapshot[]): void => {
91
143
  seedWorkflowLifecycleNotificationState(lifecycleNotificationState, {
92
144
  ...readGraphStoreSnapshot(store),
@@ -102,6 +154,7 @@ export function createWorkflowExtensionRuntimeState(
102
154
  : undefined;
103
155
  registerLifecycleNoticeRenderer({ rendererHost: pi, registerMessageRenderer });
104
156
  registerHilAnswerNoticeRenderer({ rendererHost: pi, registerMessageRenderer });
157
+ registerWorkflowHeartbeatRenderer({ rendererHost: pi, registerMessageRenderer });
105
158
  const sendWorkflowNotificationMessage: ExtensionAPI["sendMessage"] | undefined =
106
159
  typeof pi.sendMessage === "function" ? (message, options) => pi.sendMessage!(message, options) : undefined;
107
160
  const reinstallLifecycleNotifications = (): void => {
@@ -127,6 +180,86 @@ export function createWorkflowExtensionRuntimeState(
127
180
  sendMessage: sendWorkflowNotificationMessage,
128
181
  });
129
182
  };
183
+ // `message_end` is the host's injection signal: agent-core emits it at the
184
+ // moment a message enters the conversation. That is what releases a held
185
+ // heartbeat slot.
186
+ //
187
+ // The turn-settled event is deliberately not used. The host emits it from the
188
+ // prompt cycle's `finally` whether or not the queued messages were drained, so
189
+ // pausing the queue mid-turn would release a slot whose card is still parked
190
+ // and let the next boundary stack a second card behind it. `message_end` never
191
+ // fires for a parked card. The visible display card is published on the
192
+ // session-listener channel only, so an extension sees the hidden
193
+ // reconciliation at consumption and never the card at admission — there is no
194
+ // ambiguity to disambiguate. Proven end to end against a real `AgentSession`
195
+ // in test/unit/workflow-heartbeat-parent-pickup.test.ts.
196
+ //
197
+ // Registered exactly once, at construction: `pi.on` has no unsubscribe, so a
198
+ // per-install registration would accumulate a handler on every notification
199
+ // cycle. The mutable scheduler reference is what routes the signal to the
200
+ // current installation, and a `null` reference makes it a no-op.
201
+ //
202
+ // The same condition decides whether the scheduler holds a slot at all, so the
203
+ // registration and the option can never disagree: a host that reports no
204
+ // consumption would leave a held slot with nothing to release it.
205
+ const parentAvailabilityReported = typeof pi.on === "function";
206
+ if (parentAvailabilityReported) {
207
+ // This handler observes, and for one narrow case replaces.
208
+ //
209
+ // The last guard on the heartbeat path (issue #1975): the three before it
210
+ // all sit before `sendMessage`, so a heartbeat the host has already
211
+ // admitted is beyond them. Consumption is the final moment before its
212
+ // steer joins the model's context, and `message_end` may return a
213
+ // replacement of the same role, so a heartbeat whose run has finished,
214
+ // vanished, or no longer owns that exact scheduled identity is excluded
215
+ // from context rather than steering the parent about stale work.
216
+ //
217
+ // `absent` counts as much as `terminal`. A card recovered at the restart
218
+ // door is consumed just after `session_start` cleared the store, so its run
219
+ // is normally absent. A durable resume may instead reuse the run id, but its
220
+ // future pending boundary has a different `scheduledAt`. Both cases fail
221
+ // exact pending ownership and preserve the no-backfill rule.
222
+ pi.on?.("message_end", (event, ctx) => {
223
+ // The host hands the session manager to the handler on its context; the
224
+ // top-level `pi.sessionManager` is not populated in every host, so the
225
+ // per-call one is preferred and the other is the fallback. Verified
226
+ // against a real `AgentSession` in
227
+ // test/unit/workflow-heartbeat-parent-pickup.test.ts.
228
+ const entries = (ctx?.sessionManager ?? pi.sessionManager)?.getEntries?.();
229
+ const invalidation = workflowHeartbeatContextInvalidation(event, entries, isWorkflowHeartbeatIdentityOwned);
230
+ if (invalidation !== undefined) return invalidation;
231
+ const identity = workflowHeartbeatConsumedIdentity(event, entries);
232
+ if (identity !== undefined) workflowHeartbeatScheduler?.notifyHeartbeatConsumed(identity);
233
+ return undefined;
234
+ });
235
+ }
236
+
237
+ /** Whether this exact identity is still pending for a current nonterminal run. */
238
+ function isWorkflowHeartbeatIdentityOwned(identity: WorkflowHeartbeatIdentity): boolean {
239
+ const pending = workflowHeartbeatSchedulerState.pending.get(identity.runId);
240
+ const run = store.runs().find((candidate) => candidate.id === identity.runId);
241
+ return pending?.scheduledAt === identity.scheduledAt && run !== undefined && !isWorkflowHeartbeatTerminalRun(run);
242
+ }
243
+
244
+ /**
245
+ * Workflow heartbeats share the lifecycle-notice lifetime: they are armed
246
+ * while notifications are active and disposed with them. The cadence itself
247
+ * comes from the live registry, so a workflow reload is picked up without a
248
+ * reinstall.
249
+ */
250
+ const reinstallWorkflowHeartbeatScheduler = (): void => {
251
+ workflowHeartbeatScheduler?.dispose();
252
+ workflowHeartbeatScheduler = null;
253
+ if (!notificationsActive) return;
254
+ workflowHeartbeatScheduler = installWorkflowHeartbeatScheduler({
255
+ store,
256
+ state: workflowHeartbeatSchedulerState,
257
+ sendMessage: sendWorkflowNotificationMessage,
258
+ resolveIntervalMinutes: (workflowName) => runtimeProxy.registry.get(workflowName)?.heartbeatIntervalMinutes,
259
+ parentAvailabilityReported,
260
+ anchorStore: durableWorkflowHeartbeatAnchorStore(),
261
+ });
262
+ };
130
263
 
131
264
  const hostStageSessionDir: { current: string | undefined } = { current: undefined };
132
265
  const resolveDefaultStageSessionDir = (): string | undefined => hostStageSessionDir.current;
@@ -425,6 +558,7 @@ export function createWorkflowExtensionRuntimeState(
425
558
  discoveryRef,
426
559
  lifecycleNotificationState,
427
560
  hilAnswerNotificationState,
561
+ workflowHeartbeatSchedulerState,
428
562
  beforeRestoreCompleted,
429
563
  runtimeForContext,
430
564
  resetWorkflowDiscoveryForSession,
@@ -442,6 +576,10 @@ export function createWorkflowExtensionRuntimeState(
442
576
  notificationsActive = active;
443
577
  reinstallLifecycleNotifications();
444
578
  reinstallHilAnswerNotifications();
579
+ // A fresh host session restarts the cadence from each run's persisted
580
+ // start time; prior-session schedule and pending state cannot apply.
581
+ if (!active) resetWorkflowHeartbeatSchedulerState(workflowHeartbeatSchedulerState);
582
+ reinstallWorkflowHeartbeatScheduler();
445
583
  },
446
584
  updateHostStageSessionDir(sessionManager) {
447
585
  try {