@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.
- package/CHANGELOG.md +93 -0
- package/dist/builtin/i-have-adhd/CHANGELOG.md +13 -0
- package/dist/builtin/i-have-adhd/package.json +1 -1
- package/dist/builtin/intercom/CHANGELOG.md +26 -0
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/CHANGELOG.md +15 -0
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +41 -0
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/CHANGELOG.md +14 -0
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +58 -0
- package/dist/builtin/workflows/README.md +9 -0
- package/dist/builtin/workflows/builtin/adversarial-verification.ts +4 -0
- package/dist/builtin/workflows/builtin/classify-and-act.ts +4 -0
- package/dist/builtin/workflows/builtin/fan-out-and-synthesize.ts +4 -0
- package/dist/builtin/workflows/builtin/generate-and-filter.ts +4 -0
- package/dist/builtin/workflows/builtin/goal.ts +4 -0
- package/dist/builtin/workflows/builtin/loop-until-done.ts +4 -0
- package/dist/builtin/workflows/builtin/open-claude-design.ts +4 -0
- package/dist/builtin/workflows/builtin/ralph.ts +4 -0
- package/dist/builtin/workflows/builtin/tournament.ts +4 -0
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/authoring/workflow.ts +9 -0
- package/dist/builtin/workflows/src/authoring.d.ts +3 -0
- package/dist/builtin/workflows/src/durable/completed-catalog.ts +13 -2
- package/dist/builtin/workflows/src/durable/workflow-heartbeat-anchor.ts +153 -0
- package/dist/builtin/workflows/src/engine/run-durable-stage-session.ts +28 -0
- package/dist/builtin/workflows/src/engine/run.ts +1 -0
- package/dist/builtin/workflows/src/extension/extension-runtime-state.ts +138 -0
- package/dist/builtin/workflows/src/extension/index.bundle.mjs +766 -4
- package/dist/builtin/workflows/src/extension/workflow-heartbeat-delivery.ts +197 -0
- package/dist/builtin/workflows/src/extension/workflow-heartbeat-notice.ts +115 -0
- package/dist/builtin/workflows/src/extension/workflow-heartbeat-scheduler.ts +962 -0
- package/dist/builtin/workflows/src/sdk-surface.ts +9 -0
- package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +1 -0
- package/dist/builtin/workflows/src/shared/authoring-contract-ui.ts +1 -0
- package/dist/builtin/workflows/src/shared/persistence-restore.ts +12 -0
- package/dist/builtin/workflows/src/shared/workflow-authoring-types.d.ts +1 -0
- package/dist/builtin/workflows/src/shared/workflow-authoring-types.ts +1 -0
- package/dist/builtin/workflows/src/shared/workflow-heartbeat-contract.d.ts +21 -0
- package/dist/builtin/workflows/src/shared/workflow-heartbeat-contract.ts +25 -0
- package/dist/core/extensions/index.d.ts +1 -0
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js +1 -0
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/ui-types.d.ts +26 -0
- package/dist/core/extensions/ui-types.d.ts.map +1 -1
- package/dist/core/extensions/ui-types.js +17 -1
- package/dist/core/extensions/ui-types.js.map +1 -1
- package/dist/core/tools/ask-user-question/ask-user-question.d.ts +21 -0
- package/dist/core/tools/ask-user-question/ask-user-question.d.ts.map +1 -1
- package/dist/core/tools/ask-user-question/ask-user-question.js +34 -9
- package/dist/core/tools/ask-user-question/ask-user-question.js.map +1 -1
- package/dist/core/tools/ask-user-question/view/components/multi-select-view.d.ts.map +1 -1
- package/dist/core/tools/ask-user-question/view/components/multi-select-view.js +5 -2
- package/dist/core/tools/ask-user-question/view/components/multi-select-view.js.map +1 -1
- package/dist/core/tools/ask-user-question/view/components/submit-picker.d.ts.map +1 -1
- package/dist/core/tools/ask-user-question/view/components/submit-picker.js +3 -1
- package/dist/core/tools/ask-user-question/view/components/submit-picker.js.map +1 -1
- package/dist/core/tools/ask-user-question/view/components/wrapping-select.d.ts.map +1 -1
- package/dist/core/tools/ask-user-question/view/components/wrapping-select.js +8 -1
- package/dist/core/tools/ask-user-question/view/components/wrapping-select.js.map +1 -1
- package/dist/index-extensions.d.ts +1 -1
- package/dist/index-extensions.d.ts.map +1 -1
- package/dist/index-extensions.js +1 -1
- package/dist/index-extensions.js.map +1 -1
- package/dist/modes/interactive/components/reserved-bottom-overlay.d.ts +95 -0
- package/dist/modes/interactive/components/reserved-bottom-overlay.d.ts.map +1 -0
- package/dist/modes/interactive/components/reserved-bottom-overlay.js +234 -0
- package/dist/modes/interactive/components/reserved-bottom-overlay.js.map +1 -0
- package/dist/modes/interactive/interactive-extension-custom-ui.js +94 -6
- package/dist/modes/interactive/interactive-extension-custom-ui.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-base.d.ts +3 -0
- package/dist/modes/interactive/interactive-mode-base.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode-base.js +5 -1
- package/dist/modes/interactive/interactive-mode-base.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-surface.d.ts +2 -0
- package/dist/modes/interactive/interactive-mode-surface.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode-surface.js.map +1 -1
- package/dist/modes/interactive/interactive-tui.d.ts +3 -0
- package/dist/modes/interactive/interactive-tui.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-tui.js +14 -0
- package/dist/modes/interactive/interactive-tui.js.map +1 -1
- package/dist/modes/interactive-engine/engine-custom-ui.d.ts +2 -0
- package/dist/modes/interactive-engine/engine-custom-ui.d.ts.map +1 -1
- package/dist/modes/interactive-engine/engine-custom-ui.js +1 -0
- package/dist/modes/interactive-engine/engine-custom-ui.js.map +1 -1
- package/dist/modes/interactive-engine/protocol.d.ts +1 -0
- package/dist/modes/interactive-engine/protocol.d.ts.map +1 -1
- package/dist/modes/interactive-engine/protocol.js +1 -0
- package/dist/modes/interactive-engine/protocol.js.map +1 -1
- package/dist/modes/interactive-engine/remote-component.d.ts +3 -1
- package/dist/modes/interactive-engine/remote-component.d.ts.map +1 -1
- package/dist/modes/interactive-engine/remote-component.js +3 -2
- package/dist/modes/interactive-engine/remote-component.js.map +1 -1
- package/docs/extensions.md +27 -0
- package/docs/keybindings.md +1 -0
- package/docs/workflows.md +34 -0
- package/npm-shrinkwrap.json +29 -29
- 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({
|
|
@@ -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
|
|
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
|
|
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 {
|