feat(core): scheduled agent heartbeats (core + server + client-js)#18184
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
🦋 Changeset detectedLatest commit: 363f06a The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds heartbeat scheduling for agents, including storage-backed CRUD, runtime execution, scheduler support, server routes, client APIs, permissions, tests, and documentation. ChangesAgent Heartbeats
Estimated code review effort🎯 5 (Critical) | ⏱️ ~90+ minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Adds heartbeats: a first-class `type: 'heartbeat'` schedule target that runs an agent periodically on a cron — either inside an existing thread (via agent.sendSignal(), so subscribers receive the message through the normal channel pipeline) or in isolation (via a one-off agent.generate()). Backed by HeartbeatWorker subscribing to the 'heartbeats' pubsub topic published by the Scheduler. Each fire wakes/delivers/persists/discards/skips per the heartbeat's ifActive/ifIdle policy and lifecycle hooks (prepare/onFinish/onError/onAbort). Broadcast modes (live/on-complete/never) are enforced channel-side. The worker signal consumer is written against the unified `accepted: Promise<SendAgentSignalAccepted>` API: it awaits the settled result, narrows the discriminated union by action (present-tense deliver/persist/discard/wake), and derives runId asymmetrically via 'runId' in settled. Scope (PR1 of stacked pair per ship plan): @mastra/core, @mastra/server, @mastra/client-js. Studio/playground UI follows in the stacked PR2. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
06f4616 to
0fdf149
Compare
…ats as the sole CRUD surface
Collapse heartbeat management onto a single canonical surface so there is
one API to document, test, and keep in sync. Callers scope to an agent by
passing agentId to create/list (mastra.heartbeats.list({ agentId })), which
the getter only wrapped anyway.
- Remove the agent.heartbeats getter and its now-unused type imports.
- Remove Heartbeats.listTriggers and the heartbeat-specific triggers route;
trigger listing for any schedule is already served by the generic
/schedules/:scheduleId/triggers endpoint, which skips workflow-run
hydration when target.type !== 'workflow'.
- Drop the HeartbeatTriggerRow re-exports left dangling by the above.
- Retarget api.test.ts to mastra.heartbeats.*; the getter-only ownership
guards (owner-mismatch, non-owned get) no longer apply, replaced the
unregistered-agent guard with a missing-agentId guard.
- Regenerate client-js route types and CLI route metadata.
Agent-constructor heartbeat hooks (config.heartbeat) are intentionally left
in place; hook placement is a separate, still-open v1 decision.
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
Move heartbeat lifecycle hooks from the Agent constructor to the Mastra
constructor, keyed by agentId, so they apply to both code-defined and
stored agents (whose serialized config cannot carry functions). Adds
heartbeat?: HeartbeatConfig ({ hooks?: Record<agentId, HeartbeatHooks> })
to the Mastra config and a __getHeartbeatHooks(agentId) accessor the
worker resolves against; removes the agent-level heartbeat hooks field,
#heartbeatHooks, and __getHeartbeatHooks().
Drop the static activeHours window. With cadence locked to plain cron,
a clock-window filter is a lossier re-encoding of cron's hour/day fields
and a footgun: it only suppresses a fire, never advances nextFireAt, so a
coarse cron + narrow window silently skips forever. Static windows belong
in cron; dynamic gating stays via idleThresholdMs and the prepare-hook
skip. Removes the target field, type/schema, the skipped-outside-hours
status, the isWithinActiveHours worker gate, the server route
schemas/handler plumbing, and regenerates CLI route metadata.
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…removal Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…heartbeats sugar The changeset still documented the removed agent.heartbeats.* sugar and the agent-constructor heartbeat hooks. Realign with the shipped surface: mastra.heartbeats.* as the sole CRUD entrypoint and Mastra-level heartbeat.hooks keyed by agentId. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
… hook context
Hooks register as a flat bundle on the Mastra constructor
(new Mastra({ heartbeat: { onFinish, prepare, ... } })) instead of being
keyed by agentId. agentId is now provided on each hook context, so a single
hook set can serve all agents and branch on ctx.agentId. Also adds the
heartbeats docs page.
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
The heartbeats work retargeted ScheduleTriggerOutcome to the shipped outcome set and dropped the older queue/notification outcomes (acked, alerted, deferred, appended-from-queue, dropped-stale, dropped-superseded, dropped-busy). Trigger rows persisted by older builds may still carry those strings; dropping them from the union makes such rows lie about their value and can break exhaustive handling when listing triggers. Retain the legacy literals in the union (annotated as no-longer-written) so reads stay correctly typed without resurrecting their write paths. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
heartbeats.update({ status: 'active' }) on a paused heartbeat flipped the
status field directly but left the stale nextFireAt from when it was
paused — often in the past — causing an immediate spurious fire. The
dedicated resume() method recomputes nextFireAt forward from now; update()
now mirrors that when a patch transitions paused -> active, so both entry
points share the same resume semantics. Non-resume updates leave
nextFireAt untouched.
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
Resolve the quick-win review threads on the heartbeats PR so the public surface and docs match the shipped behavior: - Keep legacy trigger-outcome values readable in the server response validator (zod enum widened to mirror the core ScheduleTriggerOutcome union) so trigger rows persisted by older builds still parse. - Correct the documented threaded-signal default to 'system-reminder' (the worker default), not 'user-message'. - Use the public /api/heartbeats path in the changeset. - Update listSchedules() JSDoc to mention the owner filters it accepts. - Translate getAgentById() (which throws on miss) into a clean 404 in the create-heartbeat handler instead of relying on an undefined return. - Always shut down Mastra instances in the heartbeat integration tests via an afterEach registry so a failing assertion cannot leak workers. - Document the broadcast option (live / on-complete / never) in the docs. - Drop internal mechanics (workflow/HeartbeatWorker/processor) from client-js JSDoc; keep observable behavior only. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
validateCron now rejects missing or empty cron strings with an actionable message and wraps croner's raw parser errors (e.g. "Pattern has to be of type string.") with the offending pattern. Previously calling heartbeats.create() or scheduling a workflow without a cron surfaced a wall of minified croner internals instead of a usable error. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/workflows/scheduler/cron.ts`:
- Around line 15-22: The catch in validateCron currently wraps both invalid cron
patterns and invalid timezone errors into the same “Invalid cron expression”
message. Update validateCron to distinguish failures from new Cron(cron, {
timezone }) / job.nextRun() so timezone-related errors are reported as timezone
validation issues, while cron parsing errors still mention the cron expression.
Keep the behavior covered by validateCron in sync with the expectations in
cron.test.ts for invalid timezone input.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 07a97bb6-8b62-4ffe-8fd9-9349592eafdf
📒 Files selected for processing (2)
packages/core/src/workflows/scheduler/cron.test.tspackages/core/src/workflows/scheduler/cron.ts
✅ Files skipped from review due to trivial changes (1)
- packages/core/src/workflows/scheduler/cron.test.ts
validateCron's catch path also caught croner's lazy timezone validation, so a bad timezone was reported as an invalid cron expression. Validate the cron pattern and the timezone separately so each failure is labeled correctly. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…ifIdle defaults to sendSignal idleThresholdMs was a fuzzy idle proxy (thread updatedAt) that overlapped with ifActive routing and added API surface for behavior ifActive already covers. Removing it across core, server, client-js, and docs. The heartbeat worker no longer synthesizes its own ifActive/ifIdle defaults. When the caller omits them, nothing is passed and sendSignal applies its own defaults (deliver/wake), keeping heartbeat delivery consistent with direct signal usage instead of diverging (worker previously defaulted ifActive to discard). HeartbeatIfActive/HeartbeatIfIdle now alias the signal behavior unions so the two stay in lockstep. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
A guard added in #18183 (shouldStartBroadcastOnRegister) only started the broadcast pump when `fullStream` was an own data property on the output. On real `agent.stream()` results `fullStream` is a getter, so the check was always false and no real threaded run self-pumped. Interactive runs only completed because the caller iterated the returned stream; a fire-and-forget wake (e.g. a heartbeat) had no consumer, so the run never reached a terminal state and its active-run record + thread lease never released — permanently wedging the thread so every later signal coalesced into the stuck run. Always start the broadcast pump on register. The broadcast tee buffers every part, so a later/external subscriber still replays the full stream, while the run now completes on its own. Also remove debug console.log statements left in agent.ts, agent-channels.ts, and the heartbeat worker. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…ccepted # Conflicts: # client-sdks/client-js/src/route-types.generated.ts # packages/_internals/auth/src/ee/interfaces/permissions.generated.ts # packages/server/scripts/permission-generator.ts # packages/server/src/server/server-adapter/routes/index.ts
Heartbeat ids were always random hb_<uuid>. Callers often want a stable, predictable handle (e.g. 'nightly-summary') to look up, update, or delete a heartbeat without first persisting the generated id. Accept an optional id on create that is normalized to hb_<slug> (prefix added if missing, rest slugified) and reject duplicates so a typo doesn't silently clobber an existing schedule. Threaded through core, the /heartbeats POST body, and the client-js createHeartbeat input. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
… pre mode Regenerate route-types.generated.ts and route-metadata.generated.ts to include the optional heartbeat `id` field added to POST /heartbeats. Restore .changeset/pre.json so the version validation runs in pre mode, matching main. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
Heartbeat config now mirrors the agent.sendSignal options surface while
staying JSON-serializable for schedule storage.
- ifActive/ifIdle widen from bare behavior strings to { behavior, attributes }
(plus streamOptions on ifIdle), matching SendAgentSignalOptions.
- ifIdle.streamOptions accepts a serializable requestContext that the worker
rehydrates into a RequestContext on the woken run, so heartbeat-woken runs
can carry channel/render context.
- Top-level providerOptions persist through create/update/rehydrate and merge
into the signal payload on every fire, regardless of ifActive/ifIdle.
- Keeps the notification signalType / heartbeat tagName defaults, top-level
attributes, and resolveHeartbeatId lookup symmetry.
This is a breaking shape change to an unreleased API (heartbeats ship in
PR #18184); folded into the existing unreleased changeset. Docs and the
changeset example updated to the object-shaped API.
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…ough HTTP surface The core heartbeat target gained object-shaped ifActive/ifIdle plus tagName, attributes, providerOptions, and ifIdle.streamOptions.requestContext, but the server route schemas, handler threading, and client-js types still described the old bare-string shape. That left the server build failing (response schema no longer assignable to ScheduleTarget) and the generated route-types stale. - Widen the heartbeat schemas in @mastra/server (create/update body, view, and the schedule target) to the object shape with shared ifActive/ifIdle/attributes fragments; thread tagName/attributes/providerOptions through the create/update handlers. - Mirror the same shape on the handwritten @mastra/client-js Heartbeat, CreateHeartbeatInput, and UpdateHeartbeatOptions types. - Regenerate route-types.generated.ts and route-metadata.generated.ts so build output validation is clean. - Fix resolveHeartbeatId: an id that already carries the hb_ prefix is a fully-formed stored id and must resolve verbatim. Re-slugifying it mangled characters create already accepted, making the heartbeat unaddressable via get/update/delete. Bare caller ids still canonicalize to hb_<slug>. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…ixture The custom-id symmetry fix made heartbeat lookups (get/update/delete/ pause/resume/run) canonicalize a bare path-param id to hb_<slug> before hitting storage. The shared adapter route fixture seeded a bare 'test-heartbeat' id, which no longer matched the canonicalized lookup, so every heartbeat route returned 404. Real heartbeats are always stored with the hb_ prefix, so seed the fixture (and its path-param default) as 'hb_test-heartbeat' to match production id shape. resolveHeartbeatId returns hb_-prefixed ids verbatim, so the lookup now resolves. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
The broadcast field was advisory-only: it was stamped on providerOptions.mastra.heartbeat but nothing read it, so it had no effect on a heartbeat run's behavior. Removing it keeps the v1 surface honest rather than shipping a dead knob. Future channel-broadcast policy will ride ifIdle.streamOptions.requestContext via a well-known key, which scopes the concern to channels instead of a top-level heartbeat field. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
The test asserted providerOptions.mastra.heartbeat passthrough on a downstream stream chunk that no consumer reads, and its comments described AgentChannels heartbeat detection that was never built. The real worker-boundary contract (providerOptions merged with heartbeat run metadata) is already covered by worker.test.ts. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
…ate() create() already rejects signalType/ifActive/ifIdle on threadless heartbeats. Mirror that guard in update() so the invalid state can't be set after the fact.
What
Adds heartbeats — a first-class
type: 'heartbeat'schedule target that runs an agent periodically on a cron. A heartbeat either:agent.sendSignal(), so subscribers receive the message through the normal channel pipeline, oragent.generate().Each heartbeat has a random
hb_<uuid>id and an optionalname, so one agent/thread can host multiple heartbeats with different crons and prompts.API
New methods on the Mastra
heartbeatsservice:create/update/get/list/pause/resume/delete. Each heartbeat carries acron, optionaltimezone, aprompt, optionalthreadId/resourceId,ifActive/ifIdlepolicy, and lifecycle hooks. HTTP routes and a@mastra/client-jsclient surface the same operations.How it works
mastra_schedulesrow (target.type === 'heartbeat'); onemastra_schedule_triggersrow per fire.heartbeatspubsub topic; aHeartbeatWorkersubscribes and runs the agent.ifActive/ifIdlepolicy.prepare/onFinish/onError/onAbort.ScheduleTargetvariant (not a hidden internal workflow), so it shows up cleanly in schedule listings and trigger history.Signals integration
The worker signal consumer is written against the unified
accepted: Promise<SendAgentSignalAccepted>API:action(deliver/persist/discard/blocked/wake),blocked(thread can't currently accept the signal) to askipped-thread-blockedoutcome rather than treating it as success,runIdasymmetrically via'runId' in settled,accepted(misconfig) as an error outcome.Scope
@mastra/core+@mastra/server+@mastra/client-js. Studio/playground UI follows separately.Tests
The
update … recomputes nextFireAttest is now pinned with fake timers so it's deterministic (previously wallclock-flaky at the top of the hour, where*/5 * * * *and0 * * * *resolve to the same next-fire timestamp).ELI5
This PR adds heartbeats: a way to make an agent automatically run over and over on a cron schedule. Each heartbeat can either poke an existing chat thread or run by itself, and you can manage it with server endpoints or the client SDK.
Summary
type: 'heartbeat') across@mastra/core,@mastra/server, and@mastra/client-js.mastra.heartbeatsservice with:create,get,list,update,pause,resume,delete, and manualrun.Schedulermodel:heartbeatspubsub topicHeartbeatWorkerexecutes each tick using:threadId+resourceId) viaagent.sendSignal()agent.generate()in isolationMastraconstructor (heartbeat.prepare,onFinish,onError,onAbort), including:preparereturnsnull)triggerKind: 'manual')/api/heartbeats(list/get/create/update/delete/pause/resume/run), including corresponding schemas and generated route typings/metadata.MastraClientclient-js APIs mirroring server operations, plus updated SDK types for heartbeat targets, run outcomes, and route contracts.heartbeatsresource/action support.validateCronnow clearly distinguishes invalid cron from invalid timezone errors, and rejects empty/whitespace cron with an explicit message.