Skip to content

feat(coding-agent): add persistent session heartbeat - #165

Closed
sethkarten wants to merge 1 commit into
mainfrom
feature/agent-cron-jobs
Closed

feat(coding-agent): add persistent session heartbeat#165
sethkarten wants to merge 1 commit into
mainfrom
feature/agent-cron-jobs

Conversation

@sethkarten

@sethkarten sethkarten commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Context

This is a draft implementation of persistent session heartbeats for long-running Prime Agent work. The goal is to let a daemon-backed session keep checking in on a cadence without needing an active /goal loop or an external tmux/script wrapper.

What changed

  • Adds durable daemon-backed scheduler storage at cron-jobs.json under the agent config directory.
  • Makes /heartbeat a goal-style single persistent session state:
    • /heartbeat or /heartbeat status
    • /heartbeat [--every <interval>] <instruction>
    • /heartbeat pause
    • /heartbeat resume
    • /heartbeat clear or /heartbeat stop
  • Defaults /heartbeat <instruction> to every 5m when no interval is specified.
  • Replaces the old model tool draft with goal-like tools: get_heartbeat, create_heartbeat, and update_heartbeat.
  • Supports recurring intervals like every 30s, 30s, every 10m, cron aliases like @hourly, and five-field cron expressions through the heartbeat scheduler.
  • Keeps the slash-command surface heartbeat-only; there is no /cron command in this draft.

How to try it

In a daemon-backed Prime Agent session:

/heartbeat check on me
/heartbeat status
/heartbeat --every 30s check on me
/heartbeat pause
/heartbeat resume
/heartbeat clear

Natural-language self-setup should work when tools are enabled, e.g.:

Set up a recurring heartbeat every 30 seconds to check on me.

Validation

  • npm --workspace packages/coding-agent test -- cron-jobs.test.ts daemon-command.test.ts slash-commands.test.ts
  • npm run check
  • Pre-commit hook reran npm run check.

I also ran the full npm --workspace packages/coding-agent test; it still has unrelated extension-loader environment failures because local tests cannot import @earendil-works/pi-agent-core/dist/index.js, plus one existing git watcher timeout.

Review focus

  • Whether /heartbeat should mirror /goal this closely.
  • Whether the default interval should stay 5m.
  • Whether seconds-level intervals should stay in this draft or be limited later.
  • Whether richer TUI management should be separate from this first pass.

Known gaps / intentional limits

  • Heartbeat schedules must be recurring.
  • Fixed intervals support seconds/minutes/hours; cron expressions remain minute-granularity and local-time only.
  • No weekday/month names like MON, no cron seconds field, and no timezone support for cron expressions.
  • In-process connections can inspect no heartbeat but cannot set one; heartbeat scheduling is daemon-backed.

Note

Medium Risk
Background scheduler can inject prompts into live sessions (including model-initiated heartbeats), affecting cost and behavior; daemon now hard-requires agentDir and runs timers for the process lifetime.

Overview
Adds daemon-backed scheduled prompts so long-running sessions can fire agent turns on a timer without relying on /goal or external wrappers.

A new cron-jobs.json store and in-process scheduler support one-shot (in 10m, at <date>), interval (every 30s), and five-field cron schedules. When a job is due, the daemon loads or reattaches the target session and calls prompt with the stored text (follow-up if the session is already streaming).

User-facing surfaces: daemon cron list|add|cancel (add uses -- between schedule and message), /heartbeat in interactive mode (goal-style status / set / pause / resume / clear, default every 5m), and model tools get_heartbeat, create_heartbeat, update_heartbeat on daemon sessions. Heartbeats are a single recurring job per session (source: heartbeat); general cron jobs can target any session. In-process AgentConnection stubs return empty or error for these APIs.

Daemon startup now requires agentDir, starts the scheduler, and stops it on shutdown; protocol and client adapters gain cron_* and heartbeat_* commands.

Reviewed by Cursor Bugbot for commit 9cf6fb0. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add persistent session heartbeat and cron job scheduling to the coding agent daemon

  • Introduces AgentCronJobStore and AgentCronScheduler in cron-jobs.ts for persistent, file-backed scheduling of prompts against long-running daemon sessions, supporting one-shot, interval, and cron expressions.
  • Adds a cron subcommand to the daemon CLI for listing, adding, and cancelling cron jobs, with -- as a separator between schedule and free-form prompt text.
  • Exposes /heartbeat as a built-in slash command in interactive mode; users can set, pause, resume, clear, and inspect a per-session recurring heartbeat from the UI.
  • Extends the AgentConnection interface and both daemon and in-process implementations with listCronJobs, addCronJob, cancelCronJob, getHeartbeat, setHeartbeat, and updateHeartbeat methods; in-process connections throw to signal daemon-only capability.
  • Wires heartbeat tool definitions into daemon session runtimes (including subagent runtimes) so the model can manage heartbeats directly as tools.
  • Risk: daemon now requires agentDir to be set (throws in constructor if missing), and the scheduler starts on daemon startup, adding background timer activity.

Macroscope summarized 9cf6fb0.

@sethkarten
sethkarten force-pushed the feature/agent-cron-jobs branch from 10b95f3 to 1a1d1bc Compare June 15, 2026 20:44
Comment thread packages/coding-agent/src/cli/daemon-command.ts
case "cron_cancel": {
const job = this.cronStore.cancel(command.jobId);
if (!job) {
throw new Error(`No cron job found: ${command.jobId}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low daemon/daemon-mode.ts:851

When cancelling an already-cancelled cron job, the daemon throws "No cron job found" even though the job exists. This happens because AgentCronJobStore.cancel() returns undefined for both "job not found" and "already cancelled" cases (since cancelled is never assigned when `job.status === "cancelled""), and the daemon doesn't distinguish between these two outcomes. Consider checking the job's existence separately from its cancellability, or returning a clearer status from the store to distinguish "not found" from "already cancelled".

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/daemon/daemon-mode.ts around line 851:

When cancelling an already-cancelled cron job, the daemon throws "No cron job found" even though the job exists. This happens because `AgentCronJobStore.cancel()` returns `undefined` for both "job not found" and "already cancelled" cases (since `cancelled` is never assigned when `job.status === "cancelled""), and the daemon doesn't distinguish between these two outcomes. Consider checking the job's existence separately from its cancellability, or returning a clearer status from the store to distinguish "not found" from "already cancelled".

Evidence trail:
packages/coding-agent/src/core/cron-jobs.ts lines 86-99 (cancel method logic); packages/coding-agent/src/modes/daemon/daemon-mode.ts lines 848-855 (cron_cancel handler)

Comment on lines +347 to +353
private async runCronJob(job: AgentCronJob): Promise<void> {
const state = await this.getOrCreateCronJobSession(job);
await state.runtime.session.prompt(job.prompt, {
streamingBehavior: state.runtime.session.isStreaming ? "followUp" : undefined,
source: "rpc",
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium daemon/daemon-mode.ts:347

runCronJob checks state.runtime.session.isStreaming before calling prompt(), then passes streamingBehavior based on that snapshot. But prompt() internally re-checks isStreaming after multiple await calls (goal command handling, extension commands, input events). If another client sends a prompt during this window, isStreaming becomes true but streamingBehavior was already computed as undefined, causing the cron job to fail with "Agent is already processing" instead of being properly queued.

Since streamingBehavior: "followUp" is silently ignored when the agent is not streaming (the if (this.isStreaming) block is skipped entirely), consider passing it unconditionally to eliminate the race.

-		await state.runtime.session.prompt(job.prompt, {
-			streamingBehavior: state.runtime.session.isStreaming ? "followUp" : undefined,
+		await state.runtime.session.prompt(job.prompt, {
+			streamingBehavior: "followUp",
			source: "rpc",
		});
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/daemon/daemon-mode.ts around lines 347-353:

`runCronJob` checks `state.runtime.session.isStreaming` before calling `prompt()`, then passes `streamingBehavior` based on that snapshot. But `prompt()` internally re-checks `isStreaming` after multiple `await` calls (goal command handling, extension commands, input events). If another client sends a prompt during this window, `isStreaming` becomes `true` but `streamingBehavior` was already computed as `undefined`, causing the cron job to fail with "Agent is already processing" instead of being properly queued.

Since `streamingBehavior: "followUp"` is silently ignored when the agent is not streaming (the `if (this.isStreaming)` block is skipped entirely), consider passing it unconditionally to eliminate the race.

Evidence trail:
packages/coding-agent/src/modes/daemon/daemon-mode.ts:347-353 (runCronJob snapshots isStreaming before prompt call), packages/coding-agent/src/core/agent-session.ts:1883-1948 (prompt() with multiple await points before re-checking isStreaming at line 1936), packages/coding-agent/src/core/agent-session.ts:1689-1690 (isStreaming is a live getter on agent.state.isStreaming), packages/agent/src/agent.ts:470-482 (runWithLifecycle synchronously sets isStreaming = true at line 482), packages/agent/src/agent.ts:345-352 (agent.prompt() calls runPromptMessages → runWithLifecycle)

@sethkarten
sethkarten force-pushed the feature/agent-cron-jobs branch from 1a1d1bc to 0ed9b71 Compare June 15, 2026 21:19
Comment on lines +441 to +443
const [startText, endText] = rangeText.split("-");
start = parseCronNumber(startText, min, max);
end = parseCronNumber(endText, min, max);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low core/cron-jobs.ts:441

The range parsing in parseCronField silently truncates malformed ranges with multiple hyphens like "1-2-3" to "1-2". The destructuring const [startText, endText] = rangeText.split("-") ignores everything after the second hyphen, so invalid input is accepted and parsed incorrectly instead of throwing an error.

-		} else if (rangeText?.includes("-")) {
-			const [startText, endText] = rangeText.split("-");
+		} else if (rangeText?.includes("-")) {
+			const parts = rangeText.split("-");
+			if (parts.length !== 2) {
+				throw new Error(`Invalid cron range: ${rangeText}`);
+			}
+			const [startText, endText] = parts;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/cron-jobs.ts around lines 441-443:

The range parsing in `parseCronField` silently truncates malformed ranges with multiple hyphens like `"1-2-3"` to `"1-2"`. The destructuring `const [startText, endText] = rangeText.split("-")` ignores everything after the second hyphen, so invalid input is accepted and parsed incorrectly instead of throwing an error.

Evidence trail:
...

@sethkarten
sethkarten force-pushed the feature/agent-cron-jobs branch from 0ed9b71 to 7969231 Compare June 15, 2026 21:38
@sethkarten sethkarten changed the title feat(coding-agent): add self-scheduled cron jobs feat(coding-agent): add persistent session heartbeat Jun 15, 2026
Comment on lines +394 to +398
const session = state.runtime.session;
const sessionFile = session.sessionFile;
if (!sessionFile) {
throw new Error("Heartbeats require a persisted session file");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low daemon/daemon-mode.ts:394

createCronJobForState throws with the message "Heartbeats require a persisted session file" when no session file exists, but this is the cron job path, not heartbeat. This misleads users about which feature failed. Consider changing the message to reference cron jobs.

-    		throw new Error("Heartbeats require a persisted session file");
+    		throw new Error("Cron jobs require a persisted session file");
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/daemon/daemon-mode.ts around lines 394-398:

`createCronJobForState` throws with the message "Heartbeats require a persisted session file" when no session file exists, but this is the cron job path, not heartbeat. This misleads users about which feature failed. Consider changing the message to reference cron jobs.

Evidence trail:
packages/coding-agent/src/modes/daemon/daemon-mode.ts lines 393-409 (createCronJobForState with 'Heartbeats' error message at line 397), lines 411-427 (createHeartbeatForState with the same message at line 415 where it's correct). git_grep confirms both occurrences.

@sethkarten
sethkarten marked this pull request as ready for review June 15, 2026 21:53
Comment thread packages/coding-agent/src/core/cron-jobs.ts
Comment thread packages/coding-agent/src/core/cron-jobs.ts
@sethkarten
sethkarten force-pushed the feature/agent-cron-jobs branch 2 times, most recently from 9af6caa to f97e765 Compare June 15, 2026 22:00
# Conflicts:
#	packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts
#	packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts
#	packages/coding-agent/src/modes/daemon/daemon-mode.ts
@sethkarten
sethkarten force-pushed the feature/agent-cron-jobs branch from f97e765 to 9cf6fb0 Compare June 15, 2026 22:20

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9cf6fb0. Configure here.

this.registerSignalHandlers();
console.error(`Prime Agent daemon listening on ${this.socketPath}`);
void this.restoreActiveSessions();
this.cronScheduler.start();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scheduler races session restore

High Severity

The cron scheduler starts immediately while restoreActiveSessions still runs in the background. A due job can call getOrCreateCronJobSession and open the same sessionFile before restore finishes, so two runtimes may attach to one session file and interleave writes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9cf6fb0. Configure here.

@sethkarten

Copy link
Copy Markdown
Contributor Author

Superseded by #170, which is now the single combined heartbeat draft PR directly against main.

@sethkarten sethkarten closed this Jun 16, 2026
@kevinjosethomas
kevinjosethomas deleted the feature/agent-cron-jobs branch July 8, 2026 00:30
zhengr pushed a commit to zhengr/prime-agent that referenced this pull request Aug 8, 2026
- Add Mistral to KnownProvider type and model generation
- Implement Mistral-specific compat handling in openai-completions:
  - requiresToolResultName: tool results need name field
  - requiresAssistantAfterToolResult: synthetic assistant message between tool/user
  - requiresThinkingAsText: thinking blocks as <thinking> text
  - requiresMistralToolIds: tool IDs must be exactly 9 alphanumeric chars
- Add MISTRAL_API_KEY environment variable support
- Add Mistral tests across all test files
- Update documentation (README, CHANGELOG) for both ai and coding-agent packages
- Remove client IDs from gemini.md, reference upstream source instead

Closes PrimeIntellect-ai#165
zhengr pushed a commit to zhengr/prime-agent that referenced this pull request Aug 8, 2026
- Skip empty assistant messages (no content, no tool calls) to avoid
  Mistral's 'Assistant message must have either content or tool_calls'
  error
- Remove synthetic assistant bridge message after tool results (Mistral
  no longer requires this as of Dec 2024)
- Add test for empty assistant message handling

Follow-up to PrimeIntellect-ai#165
thomaswillner pushed a commit to thomaswillner/prime-agent that referenced this pull request Aug 29, 2026
Records, without editing the now-false text away, that sections 9 and 4 went
stale five hours after they were written. The correction matters more than the
content: this file exists to stop sessions trusting notes over GitHub, and it
caught its own author.

- main is 8d2139c. Between 16:12Z and 21:54Z the fleet merged PrimeIntellect-ai#279 (the PrimeIntellect-ai#58
  alert-bridge race, FIXED — stop carrying it as a standing exception), PrimeIntellect-ai#278
  (AGENTS.md invariants), PrimeIntellect-ai#283 (repo cleanup), and PrimeIntellect-ai#284, which delivered the
  last brief and closed PrimeIntellect-ai#165 with a keyword.
- Section 3's routing conclusion was confirmed by events: PrimeIntellect-ai#271 was delivered
  by the Mac maker fleet via auto-dispatch, exactly as argued, and the remote
  session correctly declined to open a second lane.
- Flags issue-state drift: PrimeIntellect-ai#271, PrimeIntellect-ai#276 and PrimeIntellect-ai#274 are delivered and merged yet
  still open, because a title reference is not a closing keyword. That is the
  mirror image of the hazard the V2 CLAUDE.md documents, and it leaves
  open-work disagreeing with main. Operator action, named as such.
- Records the residual PrimeIntellect-ai#284 deferred on stated grounds (PrimeIntellect-ai#286), which is a
  known open edge on the LIVE path.
- States the next slice: S4 / PrimeIntellect-ai#236, the first whose exit criteria need a real
  broker order. Certification stays 0/12; the system has never placed a trade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5B7QM1QLQuWSBMCxiCzS6
thomaswillner added a commit to thomaswillner/prime-agent that referenced this pull request Aug 29, 2026
…e-notes lesson (#13)

* docs(spx-v2): verification pass, queue state, and self-refinement record

No code written this session — a verification pass over already-delivered
work plus the queue-state answer.

Records, so future sessions do not repeat them:

- The audit-challenge / V1-coverage / rag-tot-cot-challenge / corrected-input
  deliverable ALREADY EXISTS (AUDIT_CHALLENGE sections A-D and
  PRIME_AGENT_INPUT_SPX_V2). An operator prompt has now asked for it in at
  least two sessions; redoing it is inventing work.
- Verified queue state from GitHub: PrimeIntellect-ai#266/PR PrimeIntellect-ai#268 merged (and PrimeIntellect-ai#263 with it,
  now main f64029a); PrimeIntellect-ai#265/PR PrimeIntellect-ai#269 and PrimeIntellect-ai#264/PR PrimeIntellect-ai#270 open with CI in flight;
  PrimeIntellect-ai#272 and PrimeIntellect-ai#271 filed, unstarted, no lane.
- Errors and corrections: settle elapsed time from GitHub workflow-run
  timestamps, never the container clock; add_repo push access was
  classifier-denied so a remote session may hold read-only and cannot push;
  register_repo_root denial falls back to reading CLAUDE.md directly.
- MATS/superpowers/routing settled empirically with the exact commands used,
  so the search is not repeated: they are Mac-harness resident, and PrimeIntellect-ai#272/PrimeIntellect-ai#271
  already carry auto-dispatch, which is what routes them to the maker fleet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5B7QM1QLQuWSBMCxiCzS6

* docs(spx-v2): implementation-session addendum — access, setup, CI outage, self-review

Appends the second half of the session to the notes: implementing PrimeIntellect-ai#272 after
the operator corrected two access assumptions.

The corrections matter more than the code:
- push DOES work; "I cannot push" was inferred from add_repo's access label
  rather than tested. A dry-run push proved it. Also: the refspec push form is
  classifier-denied while `git push -u origin <branch>` succeeds.
- this host is not the MacBook (uname, no /Users, no ~/.prime).

Also records the environment setup the Makefile assumes (venv before
v2-install, ruff 0.15.22 via python -m, seeding the gitignored account.yaml,
and proving PYTHONPATH beats editable installs in a worktree), the method that
diagnosed the repo-wide CI outage in two calls (zero recorded steps, then the
same workflow red on main), and two test defects self-review caught before
pushing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5B7QM1QLQuWSBMCxiCzS6

* docs(spx-v2): final queue state — PrimeIntellect-ai#273 merged, only PrimeIntellect-ai#271 remains

Closes the record for this session.

- All five briefed PRs merged (PrimeIntellect-ai#268, PrimeIntellect-ai#263, PrimeIntellect-ai#270, PrimeIntellect-ai#269, PrimeIntellect-ai#273); main is c84855d.
  Issues PrimeIntellect-ai#266 and PrimeIntellect-ai#272 closed by their PRs. PrimeIntellect-ai#271 is the only open brief and
  was never authorised, so never started. Runtime testing is unblocked.
- The CI outage (13:51Z-15:47Z) was account-level and hit main identically;
  recovery was visible as `changes` taking 9s with real steps instead of 2s
  with none. Nothing in the diff ever needed changing.
- Records the scope misjudgement worth carrying forward: a Codex P1 mapped
  directly to an acceptance checkbox I had deferred as out of scope. When a
  finding maps to an acceptance criterion it is in scope by definition.
- Records the auto-merge hazard: squash composes the commit message from the
  PR body, so a body left stale after a review round writes false claims into
  main permanently. Rewrite the body before merge; keep corrections visible.
- Records a published test claim that had not been executed, and the rule
  that follows from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5B7QM1QLQuWSBMCxiCzS6

* docs(spx-v2): correct the queue state — the last brief landed while idle

Records, without editing the now-false text away, that sections 9 and 4 went
stale five hours after they were written. The correction matters more than the
content: this file exists to stop sessions trusting notes over GitHub, and it
caught its own author.

- main is 8d2139c. Between 16:12Z and 21:54Z the fleet merged PrimeIntellect-ai#279 (the PrimeIntellect-ai#58
  alert-bridge race, FIXED — stop carrying it as a standing exception), PrimeIntellect-ai#278
  (AGENTS.md invariants), PrimeIntellect-ai#283 (repo cleanup), and PrimeIntellect-ai#284, which delivered the
  last brief and closed PrimeIntellect-ai#165 with a keyword.
- Section 3's routing conclusion was confirmed by events: PrimeIntellect-ai#271 was delivered
  by the Mac maker fleet via auto-dispatch, exactly as argued, and the remote
  session correctly declined to open a second lane.
- Flags issue-state drift: PrimeIntellect-ai#271, PrimeIntellect-ai#276 and PrimeIntellect-ai#274 are delivered and merged yet
  still open, because a title reference is not a closing keyword. That is the
  mirror image of the hazard the V2 CLAUDE.md documents, and it leaves
  open-work disagreeing with main. Operator action, named as such.
- Records the residual PrimeIntellect-ai#284 deferred on stated grounds (PrimeIntellect-ai#286), which is a
  known open edge on the LIVE path.
- States the next slice: S4 / PrimeIntellect-ai#236, the first whose exit criteria need a real
  broker order. Certification stays 0/12; the system has never placed a trade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5B7QM1QLQuWSBMCxiCzS6

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant