diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 861eed5ea6..e6126366a4 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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. 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)). 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)). +- 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)). ## [0.9.13] - 2026-08-13 diff --git a/packages/workflows/builtin/adversarial-verification.ts b/packages/workflows/builtin/adversarial-verification.ts index d94f620caa..3fb0b924fd 100644 --- a/packages/workflows/builtin/adversarial-verification.ts +++ b/packages/workflows/builtin/adversarial-verification.ts @@ -6,6 +6,10 @@ import { runAdversarialVerification } from "./adversarial-verification-runner.js export default workflow({ name: "adversarial-verification", description: "Produce a candidate, challenge it with fresh-context rubric-based verifiers, and reduce their evidence through a bounded repair loop.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { task: Type.String({ description: "Task whose candidate result must be independently verified." }), verifier_count: Type.Integer({ minimum: 1, maximum: 5, default: 3, description: "Number of independent verifiers per review round." }), diff --git a/packages/workflows/builtin/classify-and-act.ts b/packages/workflows/builtin/classify-and-act.ts index e35a392255..73d29b6c95 100644 --- a/packages/workflows/builtin/classify-and-act.ts +++ b/packages/workflows/builtin/classify-and-act.ts @@ -8,6 +8,10 @@ export const DEFAULT_ACTION_CATEGORIES = ["analysis", "implementation", "researc export default workflow({ name: "classify-and-act", description: "Classify a task with structured confidence, route deterministically to an isolated category action, and ask for human selection when confidence is low.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { prompt: Type.String({ description: "Task to classify and execute." }), categories: Type.Array(Type.String({ minLength: 1 }), { diff --git a/packages/workflows/builtin/fan-out-and-synthesize.ts b/packages/workflows/builtin/fan-out-and-synthesize.ts index a3f01c6720..c9eb21a60c 100644 --- a/packages/workflows/builtin/fan-out-and-synthesize.ts +++ b/packages/workflows/builtin/fan-out-and-synthesize.ts @@ -6,6 +6,10 @@ import { runFanOutAndSynthesize } from "./fan-out-and-synthesize-runner.js"; export default workflow({ name: "fan-out-and-synthesize", description: "Partition a task, run bounded independent artifact branches, then synthesize all evidence at an explicit barrier.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { prompt: Type.String({ description: "Task to partition, investigate, and synthesize." }), max_branches: Type.Integer({ diff --git a/packages/workflows/builtin/generate-and-filter.ts b/packages/workflows/builtin/generate-and-filter.ts index 53faa08728..a1f8f4301d 100644 --- a/packages/workflows/builtin/generate-and-filter.ts +++ b/packages/workflows/builtin/generate-and-filter.ts @@ -6,6 +6,10 @@ import { runGenerateAndFilter } from "./generate-and-filter-runner.js"; export default workflow({ name: "generate-and-filter", description: "Generate more independent candidates than needed, deduplicate and filter them by rubric, optionally judge them, and return a parent-consumable shortlist.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { prompt: Type.String({ description: "Prompt for candidate generation and selection." }), num_candidates: Type.Integer({ minimum: 2, maximum: 20, default: 8, description: "Number of independent candidates to generate." }), diff --git a/packages/workflows/builtin/goal.ts b/packages/workflows/builtin/goal.ts index c1d2cae25b..721237b631 100644 --- a/packages/workflows/builtin/goal.ts +++ b/packages/workflows/builtin/goal.ts @@ -15,6 +15,10 @@ import { DEFAULT_MAX_TURNS } from "./goal-types.js"; export default workflow({ name: "goal", 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.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { 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." }), 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." })), diff --git a/packages/workflows/builtin/loop-until-done.ts b/packages/workflows/builtin/loop-until-done.ts index f7c5a9cc6a..ed7a161a3c 100644 --- a/packages/workflows/builtin/loop-until-done.ts +++ b/packages/workflows/builtin/loop-until-done.ts @@ -6,6 +6,10 @@ import { runLoopUntilDone } from "./loop-until-done-runner.js"; export default workflow({ name: "loop-until-done", description: "Repeat evidence-producing work and independent completion evaluation against a durable ledger until done or an inspectable iteration-limit failure.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { prompt: Type.String({ description: "Objective whose explicit completion condition controls the bounded loop." }), max_iterations: Type.Integer({ diff --git a/packages/workflows/builtin/open-claude-design.ts b/packages/workflows/builtin/open-claude-design.ts index 6af5ee74b9..f3d56aa6dd 100644 --- a/packages/workflows/builtin/open-claude-design.ts +++ b/packages/workflows/builtin/open-claude-design.ts @@ -27,6 +27,10 @@ import { export default workflow({ name: "open-claude-design", 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.", + // Disabled: this workflow is driven by the user reviewing generated HTML, + // so the parent chat is already the thing holding it to its goal. A periodic + // alignment steer would interrupt that review rather than inform it. + heartbeatIntervalMinutes: 0, inputs: { prompt: Type.String({ 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.", diff --git a/packages/workflows/builtin/ralph.ts b/packages/workflows/builtin/ralph.ts index 719338a9ea..376bd34fe5 100644 --- a/packages/workflows/builtin/ralph.ts +++ b/packages/workflows/builtin/ralph.ts @@ -13,6 +13,10 @@ import { runRalphWorkflow } from "./ralph-runner.js"; export default workflow({ name: "ralph", 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.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { 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." }), 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." })), diff --git a/packages/workflows/builtin/tournament.ts b/packages/workflows/builtin/tournament.ts index a05548fd2b..3c9ade16da 100644 --- a/packages/workflows/builtin/tournament.ts +++ b/packages/workflows/builtin/tournament.ts @@ -6,6 +6,10 @@ import { runTournament } from "./tournament-runner.js"; export default workflow({ name: "tournament", description: "Run several independent whole-task attempts through a balanced pairwise judging bracket and return an auditable winner.", + // The 15-minute default, stated rather than inherited: this is a per-workflow + // product decision, so a future change to the global default must not silently + // re-cadence a long autonomous run. + heartbeatIntervalMinutes: 15, inputs: { prompt: Type.String({ description: "Task every competing agent must attempt independently." }), num_attempts: Type.Integer({ diff --git a/test/unit/builtin-workflow-heartbeat-cadence.test.ts b/test/unit/builtin-workflow-heartbeat-cadence.test.ts new file mode 100644 index 0000000000..1fd462f171 --- /dev/null +++ b/test/unit/builtin-workflow-heartbeat-cadence.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; +import adversarialVerification from "../../packages/workflows/builtin/adversarial-verification.js"; +import classifyAndAct from "../../packages/workflows/builtin/classify-and-act.js"; +import fanOutAndSynthesize from "../../packages/workflows/builtin/fan-out-and-synthesize.js"; +import generateAndFilter from "../../packages/workflows/builtin/generate-and-filter.js"; +import goal from "../../packages/workflows/builtin/goal.js"; +import loopUntilDone from "../../packages/workflows/builtin/loop-until-done.js"; +import openClaudeDesign from "../../packages/workflows/builtin/open-claude-design.js"; +import ralph from "../../packages/workflows/builtin/ralph.js"; +import tournament from "../../packages/workflows/builtin/tournament.js"; +import { DEFAULT_WORKFLOW_HEARTBEAT_INTERVAL_MINUTES } from "../../packages/workflows/src/shared/workflow-heartbeat-contract.js"; + +/** + * Every builtin states its heartbeat cadence rather than inheriting it, so a + * change to the global default cannot silently re-cadence a long autonomous run + * or start heartbeating a workflow that deliberately runs quiet. + */ +describe("builtin workflow heartbeat cadences", () => { + const autonomous = [ + ["adversarial-verification", adversarialVerification], + ["classify-and-act", classifyAndAct], + ["fan-out-and-synthesize", fanOutAndSynthesize], + ["generate-and-filter", generateAndFilter], + ["goal", goal], + ["loop-until-done", loopUntilDone], + ["ralph", ralph], + ["tournament", tournament], + ] as const; + + for (const [name, definition] of autonomous) { + test(`${name} heartbeats on the 15-minute default`, () => { + assert.equal(definition.heartbeatIntervalMinutes, 15); + assert.equal(definition.heartbeatIntervalMinutes, DEFAULT_WORKFLOW_HEARTBEAT_INTERVAL_MINUTES); + }); + } + + test("open-claude-design disables heartbeats", () => { + // The user reviews generated HTML turn by turn, so the parent chat is + // already holding this workflow to its goal; a periodic alignment steer + // would interrupt that review rather than inform it. + assert.equal(openClaudeDesign.heartbeatIntervalMinutes, 0); + }); + + test("every builtin states a cadence explicitly", () => { + for (const [name, definition] of [...autonomous, ["open-claude-design", openClaudeDesign] as const]) { + assert.equal(typeof definition.heartbeatIntervalMinutes, "number", `${name} must carry a resolved cadence`); + assert.ok( + Number.isFinite(definition.heartbeatIntervalMinutes) && definition.heartbeatIntervalMinutes >= 0, + `${name} must carry a non-negative finite cadence`, + ); + } + }); +});