@bastani/atomic 0.9.13 → 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 +12 -0
- package/dist/builtin/i-have-adhd/package.json +1 -1
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +12 -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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.14-alpha.1] - 2026-08-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The whole transcript stays readable while the `ask_user_question` dialog is open** ([#2378](https://github.com/bastani-inc/atomic/issues/2378)). The dialog used to mount inline in the fullscreen dock, where it is a flex sibling of the transcript viewport, so a tall side-by-side questionnaire took the transcript's rows: on a 200×40 terminal the viewport collapsed from 34 rows to 6, and because the page step is derived from that viewport, `PageUp` crawled two lines at a time through a six-line window. The dialog is now pinned to the bottom as an overlay, which is not measured into the layout, so the transcript keeps its full viewport height and full page step — a single `PageUp` moves a full page and `Home` reaches the top of the scrollback. The overlay is also bounded so at least six transcript rows always survive on a short terminal, and the rows it does cover are added back to the transcript's scroll extent, so scrolling to the end raises the newest output into the visible strip instead of leaving it stranded behind the dialog. Transcript page, Home/End, and wheel input remain available while the questionnaire's Notes editor is active. Notes keeps text and edit keys, and the dialog still owns arrows, `enter`, `tab`, `space`, `esc`, clicks, and selection.
|
|
10
|
+
- **The active questionnaire row stays visible on a short terminal.** Bounding a tall dialog has to drop rows, and dropping them from a fixed head cropped away the control you were using: at 80 columns a single-select option disappeared as the arrow keys moved down, multi-select choices plus their Next and Submit rows vanished, and the focused Notes input was hidden. The dialog now keeps a window around every active row, including single- and multi-select choices, Next, Submit, Cancel, inline sentinels, and focused text inputs.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `ctx.ui.custom()` accepts `reserveTranscriptRows` for bottom-anchored overlay mounts. A blocking dialog sets it to be height-bounded against the terminal and to have its covered transcript-bottom suffix added to the scroll extent. Numeric and percentage `maxHeight` limits are applied before active-row windowing, so the focused control is not removed by a second fixed-head crop. Top and bottom margins are included in the overlay geometry, and concurrent overlays share the connected covered suffix instead of adding duplicate padding. Mount and resize height changes settle through one automatic repaint. A hidden overlay reserves nothing, and the reserve returns when it becomes visible again; handle-based, closed, or raw host removal releases the exact registration. Reserving overlays require a bottom anchor and reject `row` or a nonzero `offsetY`; leave the option unset for an overlay meant to take the screen, such as the workflow graph.
|
|
15
|
+
- `OVERLAY_ACTIVE_ROW_MARKER` is exported for components mounted that way. Embed it in the line you most need kept — the selected row of a list — and the bound places what it keeps around that row instead of taking a fixed head. It is a zero-width APC sequence the host strips before painting, so it never reaches the terminal.
|
|
16
|
+
|
|
5
17
|
## [0.9.13] - 2026-08-13
|
|
6
18
|
|
|
7
19
|
Cumulative release of the `0.9.13-alpha.1` – `0.9.13-alpha.4` prereleases. The summary below covers the user-visible outcome of that work; the per-change detail remains in the prerelease sections below.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bastani/intercom",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14-alpha.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Atomic extension providing a private coordination channel between parent and child agent sessions. Fork of: https://github.com/nicobailon/pi-intercom",
|
|
6
6
|
"contributors": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bastani/mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14-alpha.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Atomic extension that adapts MCP (Model Context Protocol) servers into the coding agent. Fork of: https://github.com/nicobailon/pi-mcp-adapter",
|
|
6
6
|
"contributors": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bastani/subagents",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14-alpha.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Atomic extension for delegating tasks to subagents with parallel execution. Fork of: https://github.com/nicobailon/pi-subagents",
|
|
6
6
|
"contributors": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bastani/web-access",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14-alpha.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Atomic extension for web search, URL fetching, GitHub repo cloning, PDF/video extraction. Fork of: https://github.com/nicobailon/pi-web-access",
|
|
6
6
|
"contributors": [
|
|
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.14-alpha.1] - 2026-08-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `heartbeatIntervalMinutes` to the workflow authoring surface: an optional cadence in minutes that resolves to the `15`-minute default when omitted, treats `0` as an explicit disable, and rejects negative and non-finite values with a `TypeError` while the definition is authored, so a bad cadence fails at authoring rather than at schedule time. Every compiled definition carries the resolved value, and a distinct `workflows:workflow-heartbeat` event contract is declared alongside it — run id, workflow name, start time, scheduled time, and interval, with heartbeat identity keyed by run id plus scheduled time. The default interval constant, heartbeat custom type, and heartbeat event types are exported from the `@bastani/workflows` package root ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
14
|
+
- Added workflow heartbeat scheduling and queued parent delivery for `heartbeatIntervalMinutes`, so a positive cadence now produces real heartbeats. While a top-level run is active, one heartbeat is raised per `startedAt + n × interval` boundary computed from the run's persisted start time — never from the previous delivery — so a slow send, a retry, or a process restart cannot shift the cadence. A resolved interval of `0` creates no timer and no durable record at all. One globally-next-due wake-up serves every enabled run rather than one recurring timer per run. At most one heartbeat per run is outstanding at a time: an outstanding heartbeat holds its slot until its card is actually consumed into the conversation — not merely until the parent's turn ends, which can happen while the card is still queued or its queue is paused — so a boundary that falls before that is skipped rather than stacked behind it, and the cadence then resumes at the first future boundary. However long a turn runs, and however often the queue is paused, only one unread heartbeat per run is ever waiting on a host that reports message consumption, which Atomic's chat host does. Delivery reuses the lifecycle-notice parent path (`triggerTurn`, `deliverAs: "steer"`, `persistWhenStreaming`) under the distinct `workflows:workflow-heartbeat` custom type with the five-field payload, so a busy parent chat is queued and never interrupted. Paused runs emit nothing and receive no backfill; a resumed or restarted run takes the first future boundary. Simultaneously due heartbeats reach the parent in scheduled-time order with the run id as the stable tie-break — a heartbeat being retried holds its place rather than letting a later one overtake it — terminal state is re-checked immediately before enqueue, again immediately before processing, and again before every delivery attempt including retries, and nested child workflow runs never heartbeat the parent chat. A run keeps the cadence its own definition was authored with, so editing, renaming, deleting, or reloading a workflow mid-run changes what the next launch uses and leaves runs already in flight on their launch cadence; the anchor record carries that cadence too, so it survives a durable resume. A run that launched with heartbeats disabled writes no durable record at all, so if it is resumed in a new process it adopts whatever the workflow then declares. A cadence below a millisecond schedules at the finest representable instant, and the cadence range carries a documented representable upper limit: above roughly 3 × 10^303 minutes `startedAt + interval` exceeds the largest finite timestamp, so no first boundary exists, nothing is scheduled, no durable record is written, and `ATOMIC_WORKFLOW_DEBUG=1` says so — such a cadence stays a valid positive interval but delivers no heartbeat, and `0` remains the only value that declares heartbeats off. A durable resume re-dispatches under the original run id but records a fresh start time, so each run also writes one reserved durable cadence-anchor record as soon as it has durable progress of its own, before its first boundary comes due; the anchor is read only as the earlier of itself and the run's current start time, so it keeps a resumed run on its original cadence without being able to move a boundary or raise a missed one. The delivered card names the workflow, run id, cadence, and elapsed run time and hints `/workflow status <run-id>`, rendered through the existing workflow notice-card renderer ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
15
|
+
- Added terminal heartbeat cleanup and restart recovery, so a workflow's heartbeats stop when the run does. Reaching any terminal status — completed, failed, blocked, skipped, cancelled, or killed — drops everything that run's cadence held in one pass: its armed wake-up, its next scheduled boundary, its outstanding slot and the card text held for it, any heartbeat of its own still waiting in the delivery queue along with the retry timer that belonged to it, and its cadence and durable-anchor memos. No pending timer and no queued heartbeat outlives the run that owns it. The pass is idempotent: repeating it creates no state, resurrects no schedule, and does not throw, including for a run the scheduler never saw. Both completion races converge on suppression — a run that finishes before its due boundary is enqueued has that boundary dropped and is refused at enqueue, and one that finishes after enqueue but before processing has its queued identity discarded rather than sent. Cleanup keys on the run's observed state rather than on a transition event, so it also covers the paths that reach a terminal snapshot without a normal end transition — durable replay, completed-catalog reconstruction, and a restore that inserts an already-failed run — and the same pass runs at startup, making it the recovery pass: a stale durable anchor or leftover queued record belonging to an already-terminal run is discarded rather than replayed, its anchor is neither read nor rewritten, and a run the store no longer holds is dropped the same way. An active run is never affected by another run's cleanup, a paused run keeps the floor that stops it re-raising a boundary it already delivered, and recovery for an active run still selects the first future boundary rather than replaying a missed one. A late durable acknowledgement or a send that settles after cleanup re-creates nothing. A heartbeat the chat host had already accepted into the parent's queue is past that pass, because no queued message can be withdrawn, so it is invalidated at the moment the parent reads it instead: a heartbeat whose run has since reached a terminal state, or whose run this process no longer knows at all, is excluded from the model's context and never steers the parent about a run that is over. That closes both remaining stale paths — a card parked through a long turn while its run finished, and a card recovered from a previous process at startup, which would otherwise replay a boundary raised before the restart. The card already rendered in the transcript is deliberately left in place as a true record that the heartbeat was raised; only the model-facing steer is invalidated ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
16
|
+
A run that is only blocked on a recoverable provider or rate-limit failure remains stored as `running` and resumable: it raises no new heartbeat while blocked, but keeps its cadence state and any card already waiting with the parent. Cleanup runs only when the run's own status is terminal.
|
|
17
|
+
Parent pickup is keyed by the typed `workflows:workflow-heartbeat` entry and exact run id plus scheduled time rather than rendered card text, so a foreign custom message cannot release an unread heartbeat's slot and a durable resume that reuses the run id cannot revive an old admitted card after terminal cleanup ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
18
|
+
The cadence-anchor record is now written at the durability boundary itself, so it is durable before the checkpoint that made the run resumable is acknowledged. The scheduler's own write is guarded against runs with no durable progress and can therefore only be issued after resumability already exists; a process that exited inside that window left a resumable run carrying no record of what it launched with, and the next process then took both a freshly minted start time and whatever cadence the definition declared by then, shifting the phase and the cadence of a run already in flight ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
19
|
+
- Every bundled builtin workflow now states its heartbeat cadence rather than inheriting it. `adversarial-verification`, `classify-and-act`, `fan-out-and-synthesize`, `generate-and-filter`, `goal`, `loop-until-done`, `ralph`, and `tournament` declare the `15`-minute default explicitly, so these long autonomous runs report to the parent chat on a known cadence and a future change to the global default cannot silently re-cadence them. `open-claude-design` sets `0` and runs quiet: it is driven by the user reviewing generated HTML turn by turn, so the parent chat is already holding it to its goal and a periodic alignment steer would interrupt that review rather than inform it. Behaviour is unchanged for the eight that were already resolving to the default; only `open-claude-design` changes ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
20
|
+
|
|
9
21
|
## [0.9.13] - 2026-08-13
|
|
10
22
|
|
|
11
23
|
Cumulative release of the `0.9.13-alpha.1` – `0.9.13-alpha.4` prereleases. The summary below covers the user-visible outcome of that work; the per-change detail remains in the prerelease sections below.
|
|
@@ -702,6 +702,14 @@ Because human input is runtime-only and workflows no longer carry a declaration-
|
|
|
702
702
|
|
|
703
703
|
For library or package authoring, define reusable workflows with `workflow({...})` and export the returned definition. Hand-written objects with `__piWorkflow: true` are rejected by discovery and composition; `workflow({...})` is the public authoring surface. Standalone TypeScript workflow packages import `workflow` from `@bastani/workflows` and `Type` from `typebox` directly with no local `.d.ts` file or `declare module` shim. Migration from the removed builder API is mechanical: move `.description(...)` to `description`, `.input(key, schema)` calls into `inputs`, `.output(key, schema)` calls into `outputs`, `.worktreeFromInputs(...)` to `worktreeFromInputs`, and the `.run(fn)` callback to `run: fn`; delete `.compile()`. The former imperative `runWorkflow` object-form API is removed; use workflow definitions with the exported `run()` / registry helpers for programmatic execution.
|
|
704
704
|
|
|
705
|
+
Set `heartbeatIntervalMinutes` to declare the workflow's heartbeat cadence in minutes. Omitting it uses the `15`-minute default; `0` explicitly disables heartbeats. Negative and non-finite values are rejected when the workflow definition is authored. The scheduler consumes this validated setting directly: while a top-level run is active it raises one heartbeat per `startedAt + n × interval` boundary, computed from the run's persisted start time rather than the previous delivery, so retries and restarts cannot shift the cadence. The example below heartbeats every 30 minutes, so a run started at 09:00 raises boundaries at 09:30, 10:00, 10:30, and so on.
|
|
706
|
+
|
|
707
|
+
Each heartbeat reaches the parent chat as a `workflows:workflow-heartbeat` card through the same queued-steer delivery lifecycle notices use (`triggerTurn`, `deliverAs: "steer"`, `persistWhenStreaming`), so a busy parent is never interrupted — the steer lands at its next protocol-safe boundary. At most one heartbeat per run is outstanding at a time: it holds its slot until its card is actually consumed into the conversation, not merely until the parent's turn ends, so a boundary that falls before that is skipped rather than stacked behind it and the cadence then resumes at the first future boundary. Parent pickup follows the typed heartbeat entry and releases the slot only for the exact `runId + scheduledAt` identity; another custom message cannot release it by copying the rendered text. Paused runs emit nothing and are never backfilled, a resumed or restarted run takes the first future boundary rather than bursting missed ones, simultaneous heartbeats process in `scheduledAt` order with the run id as the tie-break, and a run that reaches a terminal state is suppressed before every delivery attempt.
|
|
708
|
+
|
|
709
|
+
When a run reaches any terminal status — completed, failed, blocked, skipped, cancelled, or killed — one idempotent cleanup pass drops its wake-up timer, its next boundary, its outstanding slot, any heartbeat of its own still queued inside the scheduler with the retry timer that belonged to it, and its cadence and durable-anchor memos. Repeating the pass creates nothing and resurrects nothing. Because it reads observed state rather than transition events, it is also the recovery pass: a later process discards a stale durable anchor or leftover queued record for an already-terminal run instead of replaying it, and never replays a missed boundary for an active one. A heartbeat the host already accepted into the parent's queue is past that pass, since nothing withdraws a queued message, so it is invalidated when the parent reads it instead: its exact `runId + scheduledAt` must still be pending for a current nonterminal run. A same-ID durable resume therefore cannot revive an old card, and a heartbeat whose run is terminal or absent from this process is excluded from the model's context. That covers a card parked through a long turn and a card recovered from a previous process alike. The rendered card stays in the transcript as a record of what was raised; only the model-facing steer is invalidated ([#1975](https://github.com/bastani-inc/atomic/issues/1975)).
|
|
710
|
+
|
|
711
|
+
A recoverable provider or rate-limit block is not the terminal `blocked` status: the run remains stored as `running` and resumable. It raises no new heartbeat while blocked, but keeps its cadence state and any card already waiting with the parent; cleanup runs only once the run's own status becomes terminal.
|
|
712
|
+
|
|
705
713
|
```ts
|
|
706
714
|
import { workflow } from "@bastani/workflows";
|
|
707
715
|
import { Type } from "typebox";
|
|
@@ -709,6 +717,7 @@ import { Type } from "typebox";
|
|
|
709
717
|
export default workflow({
|
|
710
718
|
name: "audit-auth",
|
|
711
719
|
description: "Audit the authentication module.",
|
|
720
|
+
heartbeatIntervalMinutes: 30,
|
|
712
721
|
inputs: {
|
|
713
722
|
prompt: Type.String({ default: "Investigate the auth module" }),
|
|
714
723
|
},
|
|
@@ -6,6 +6,10 @@ import { runAdversarialVerification } from "./adversarial-verification-runner.js
|
|
|
6
6
|
export default workflow({
|
|
7
7
|
name: "adversarial-verification",
|
|
8
8
|
description: "Produce a candidate, challenge it with fresh-context rubric-based verifiers, and reduce their evidence through a bounded repair loop.",
|
|
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
|
task: Type.String({ description: "Task whose candidate result must be independently verified." }),
|
|
11
15
|
verifier_count: Type.Integer({ minimum: 1, maximum: 5, default: 3, description: "Number of independent verifiers per review round." }),
|
|
@@ -8,6 +8,10 @@ export const DEFAULT_ACTION_CATEGORIES = ["analysis", "implementation", "researc
|
|
|
8
8
|
export default workflow({
|
|
9
9
|
name: "classify-and-act",
|
|
10
10
|
description: "Classify a task with structured confidence, route deterministically to an isolated category action, and ask for human selection when confidence is low.",
|
|
11
|
+
// The 15-minute default, stated rather than inherited: this is a per-workflow
|
|
12
|
+
// product decision, so a future change to the global default must not silently
|
|
13
|
+
// re-cadence a long autonomous run.
|
|
14
|
+
heartbeatIntervalMinutes: 15,
|
|
11
15
|
inputs: {
|
|
12
16
|
prompt: Type.String({ description: "Task to classify and execute." }),
|
|
13
17
|
categories: Type.Array(Type.String({ minLength: 1 }), {
|
|
@@ -6,6 +6,10 @@ import { runFanOutAndSynthesize } from "./fan-out-and-synthesize-runner.js";
|
|
|
6
6
|
export default workflow({
|
|
7
7
|
name: "fan-out-and-synthesize",
|
|
8
8
|
description: "Partition a task, run bounded independent artifact branches, then synthesize all evidence at an explicit barrier.",
|
|
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 to partition, investigate, and synthesize." }),
|
|
11
15
|
max_branches: Type.Integer({
|
|
@@ -6,6 +6,10 @@ import { runGenerateAndFilter } from "./generate-and-filter-runner.js";
|
|
|
6
6
|
export default workflow({
|
|
7
7
|
name: "generate-and-filter",
|
|
8
8
|
description: "Generate more independent candidates than needed, deduplicate and filter them by rubric, optionally judge them, and return a parent-consumable shortlist.",
|
|
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: "Prompt for candidate generation and selection." }),
|
|
11
15
|
num_candidates: Type.Integer({ minimum: 2, maximum: 20, default: 8, description: "Number of independent candidates to generate." }),
|
|
@@ -15,6 +15,10 @@ import { DEFAULT_MAX_TURNS } from "./goal-types.js";
|
|
|
15
15
|
export default workflow({
|
|
16
16
|
name: "goal",
|
|
17
17
|
description: "Goal Runner workflow with bounded sub-agent orchestration turns, immutable acceptance criteria, ledger artifacts, parallel reviewers, and reducer-gated completion. When launching follow-up goal runs from review findings, pass the ORIGINAL task text as acceptance_criteria so deltas cannot drift from the literal contract. If the task includes submitting a pull request (or MR/review), remove that final action from the objective text and set create_pr=true instead when preparing the workflow inputs.",
|
|
18
|
+
// The 15-minute default, stated rather than inherited: this is a per-workflow
|
|
19
|
+
// product decision, so a future change to the global default must not silently
|
|
20
|
+
// re-cadence a long autonomous run.
|
|
21
|
+
heartbeatIntervalMinutes: 15,
|
|
18
22
|
inputs: {
|
|
19
23
|
objective: Type.String({ description: "The objective or delta for this Goal Runner workflow run. Do not include PR/MR submission instructions here; strip them from the task text and request them via create_pr=true instead." }),
|
|
20
24
|
acceptance_criteria: Type.Optional(Type.String({ description: "Original immutable task contract this run must remain consistent with. Defaults to objective. Orchestrators launching follow-up runs from reviewer findings should pass the ORIGINAL task text here." })),
|
|
@@ -6,6 +6,10 @@ import { runLoopUntilDone } from "./loop-until-done-runner.js";
|
|
|
6
6
|
export default workflow({
|
|
7
7
|
name: "loop-until-done",
|
|
8
8
|
description: "Repeat evidence-producing work and independent completion evaluation against a durable ledger until done or an inspectable iteration-limit failure.",
|
|
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: "Objective whose explicit completion condition controls the bounded loop." }),
|
|
11
15
|
max_iterations: Type.Integer({
|
|
@@ -27,6 +27,10 @@ import {
|
|
|
27
27
|
export default workflow({
|
|
28
28
|
name: "open-claude-design",
|
|
29
29
|
description: "AI-powered design workflow: combined discovery/init → design-system/reference research → curated reference discovery → HTML generation → live-driven refinement → rich HTML handoff. The discovery stage asks what to build, the output type, and which references to emulate, then runs impeccable init for PRODUCT.md/DESIGN.md (references take precedence over project context). The user iteratively reviews the generated HTML.",
|
|
30
|
+
// Disabled: this workflow is driven by the user reviewing generated HTML,
|
|
31
|
+
// so the parent chat is already the thing holding it to its goal. A periodic
|
|
32
|
+
// alignment steer would interrupt that review rather than inform it.
|
|
33
|
+
heartbeatIntervalMinutes: 0,
|
|
30
34
|
inputs: {
|
|
31
35
|
prompt: Type.String({
|
|
32
36
|
description: "What to design (for example, a dashboard, page, component, or prototype). The discovery stage refines this into a confirmed brief and asks for the output type and references.",
|
|
@@ -13,6 +13,10 @@ import { runRalphWorkflow } from "./ralph-runner.js";
|
|
|
13
13
|
export default workflow({
|
|
14
14
|
name: "ralph",
|
|
15
15
|
description: "Raw prompt → research-prompt-refinement → research → orchestrate → multi-model parallel review loop with bounded iteration and immutable acceptance criteria. When launching follow-up ralph runs from review findings, pass the ORIGINAL task text as acceptance_criteria so deltas cannot drift from the literal contract. If the task includes submitting a pull request (or MR/review), remove that final action from the prompt text and set create_pr=true instead when preparing the workflow inputs.",
|
|
16
|
+
// The 15-minute default, stated rather than inherited: this is a per-workflow
|
|
17
|
+
// product decision, so a future change to the global default must not silently
|
|
18
|
+
// re-cadence a long autonomous run.
|
|
19
|
+
heartbeatIntervalMinutes: 15,
|
|
16
20
|
inputs: {
|
|
17
21
|
prompt: Type.String({ description: "The task or goal to research, execute, and refine. Do not include PR/MR submission instructions here; strip them from the task text and request them via create_pr=true instead." }),
|
|
18
22
|
acceptance_criteria: Type.Optional(Type.String({ description: "Original immutable task contract this run must remain consistent with. Defaults to prompt. Orchestrators launching follow-up runs from reviewer findings should pass the ORIGINAL task text here." })),
|
|
@@ -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,
|