From 9cf6fb083b666067fcd1c095e7f88e5c67ae8479 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 15 Jun 2026 15:19:49 -0700 Subject: [PATCH 01/17] feat(coding-agent): add persistent session heartbeat # 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 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/cli/daemon-command.ts | 110 +++ packages/coding-agent/src/config.ts | 5 + packages/coding-agent/src/core/cron-jobs.ts | 805 ++++++++++++++++++ .../coding-agent/src/core/slash-commands.ts | 5 + .../daemon-agent-connection.ts | 53 ++ .../in-process-agent-connection.ts | 25 + .../src/modes/agent-connection/types.ts | 7 + .../src/modes/daemon/daemon-mode.ts | 202 ++++- .../src/modes/daemon/daemon-protocol.ts | 9 + .../src/modes/interactive/interactive-mode.ts | 78 ++ packages/coding-agent/test/cron-jobs.test.ts | 384 +++++++++ .../coding-agent/test/daemon-command.test.ts | 27 + .../coding-agent/test/slash-commands.test.ts | 11 + 14 files changed, 1720 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/src/core/cron-jobs.ts create mode 100644 packages/coding-agent/test/cron-jobs.test.ts create mode 100644 packages/coding-agent/test/slash-commands.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eda039a116..b28fd533f0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a first draft of daemon-backed cron jobs for scheduling prompts against long-running sessions without using `/goal`. + ## [0.1.3] - 2026-06-12 ### Added diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index b01efa8fc3..f62ea75cbb 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -7,6 +7,7 @@ import { spawn } from "child_process"; import { APP_NAME, expandTildePath } from "../config.js"; import type { AgentSessionEvent } from "../core/agent-session.js"; import type { AgentSessionRuntimeConfig } from "../core/agent-session-config.js"; +import { type AgentCronJob, formatAgentCronJob } from "../core/cron-jobs.js"; import { DaemonClient, type DaemonClientMessageListener } from "../modes/daemon/daemon-client.js"; import type { DaemonOutbound, DaemonResponse } from "../modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../modes/daemon/daemon-session-list.js"; @@ -38,6 +39,7 @@ const DAEMON_CLIENT_COMMANDS = new Set([ "messages", "stats", "commands", + "cron", "shutdown", ]); @@ -96,6 +98,12 @@ function parseDaemonClientCommand(args: string[]): ParsedDaemonClientCommand { continue; } + if (arg === "--" && command === "cron") { + positionals.push(arg); + passthrough = true; + continue; + } + if (arg === "--") { passthrough = true; continue; @@ -220,6 +228,9 @@ async function runDaemonClientCommand(parsed: ParsedDaemonClientCommand): Promis true, ); return; + case "cron": + await runCron(client, parsed.positionals, parsed.json); + return; case "shutdown": await printResponseData(client, { type: "shutdown" }, parsed.json); return; @@ -777,6 +788,79 @@ async function runMessageCommand( await printResponseData(client, { type, activeSessionId, message }, json); } +async function runCron(client: DaemonClient, args: string[], json: boolean): Promise { + const subcommand = args[0] ?? "list"; + if (subcommand === "list") { + const includeInactive = args.includes("--all") || args.includes("-a"); + const activeSessionId = args.find((arg) => !arg.startsWith("-") && arg !== "list"); + const response = await client.request({ type: "cron_list", activeSessionId, includeInactive }); + const data = requireSuccess(response); + if (json) { + printJson(data); + return; + } + const jobs = getCronJobs(data); + if (!jobs) { + printJson(data); + return; + } + if (jobs.length === 0) { + console.log("No cron jobs."); + return; + } + for (const job of jobs) { + console.log(formatAgentCronJob(job)); + } + return; + } + + if (subcommand === "add" || subcommand === "schedule") { + const separator = args.indexOf("--"); + if (separator < 0) { + throw new Error("Usage: daemon cron add -- "); + } + const activeSessionId = args[1]; + if (!activeSessionId) { + throw new Error("Usage: daemon cron add -- "); + } + const schedule = args.slice(2, separator).join(" ").trim(); + const message = args + .slice(separator + 1) + .join(" ") + .trim(); + if (!schedule || !message) { + throw new Error("Usage: daemon cron add -- "); + } + const response = await client.request({ type: "cron_add", activeSessionId, schedule, prompt: message }); + const data = requireSuccess(response); + if (json) { + printJson(data); + return; + } + const job = getCronJob(data); + console.log(job ? `Scheduled ${job.id} next=${job.nextRunAt ?? "-"}` : "Scheduled cron job."); + return; + } + + if (subcommand === "cancel" || subcommand === "delete" || subcommand === "remove") { + const jobId = args[1]; + if (!jobId) { + throw new Error("Usage: daemon cron cancel "); + } + const response = await client.request({ type: "cron_cancel", jobId }); + const data = requireSuccess(response); + if (json) { + printJson(data); + return; + } + const job = getCronJob(data); + console.log(job ? `Cancelled ${job.id}` : "Cancelled cron job."); + return; + } + + throw new Error(`Unknown cron command: ${subcommand}`); +} + async function printResponseData( client: DaemonClient, command: Parameters[0], @@ -1314,6 +1398,26 @@ function isLiveSessionSummary(value: unknown): value is SessionSummary & { activ return isSessionSummary(value) && typeof value.activeSessionId === "string"; } +function getCronJobs(value: unknown): AgentCronJob[] | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const jobs = (value as { jobs?: unknown }).jobs; + return Array.isArray(jobs) ? (jobs as AgentCronJob[]) : undefined; +} + +function getCronJob(value: unknown): { id: string; nextRunAt?: string } | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const job = (value as { job?: unknown }).job; + if (!job || typeof job !== "object" || typeof (job as { id?: unknown }).id !== "string") { + return undefined; + } + const candidate = job as { id: string; nextRunAt?: unknown }; + return { id: candidate.id, ...(typeof candidate.nextRunAt === "string" ? { nextRunAt: candidate.nextRunAt } : {}) }; +} + function printDaemonHelp(): void { console.log(`${chalk.bold("Usage:")} ${APP_NAME} daemon [options] [session name] @@ -1337,6 +1441,10 @@ ${chalk.bold("Commands:")} messages Print messages as JSON stats Print session stats as JSON commands Print available commands as JSON + cron list [-a|--all] [session] List scheduled cron jobs + cron add -- + Schedule a prompt for a session + cron cancel Cancel a scheduled cron job shutdown Stop the daemon ${chalk.bold("Options:")} @@ -1358,6 +1466,8 @@ ${chalk.bold("Examples:")} ${APP_NAME} daemon --socket /tmp/prime-agent.sock list ${APP_NAME} daemon --socket /tmp/prime-agent.sock list -a ${APP_NAME} daemon --socket /tmp/prime-agent.sock create scratch + ${APP_NAME} daemon --socket /tmp/prime-agent.sock cron add "*/30 * * * *" -- "Check progress" + ${APP_NAME} daemon --socket /tmp/prime-agent.sock cron list ${APP_NAME} daemon --socket /tmp/prime-agent.sock prompt "Say hello" ${APP_NAME} daemon --socket /tmp/prime-agent.sock attach ${APP_NAME} daemon --socket /tmp/prime-agent.sock shutdown diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 73860d491c..b63f5b85d7 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -522,6 +522,11 @@ export function getSettingsPath(): string { return join(getAgentDir(), "settings.json"); } +/** Get path to cron jobs store */ +export function getCronJobsPath(agentDir: string = getAgentDir()): string { + return join(agentDir, "cron-jobs.json"); +} + /** Get path to tools directory */ export function getToolsDir(): string { return join(getAgentDir(), "tools"); diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts new file mode 100644 index 0000000000..14e7e2b1e1 --- /dev/null +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -0,0 +1,805 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { type Static, Type } from "typebox"; +import type { ToolDefinition } from "./extensions/types.js"; + +export type AgentCronJobStatus = "active" | "paused" | "completed" | "cancelled"; +export type AgentCronScheduleKind = "once" | "cron" | "interval"; +export type AgentCronJobSource = "cron" | "heartbeat"; +export type AgentHeartbeatUpdateAction = "pause" | "resume" | "clear"; + +export interface AgentCronSchedule { + kind: AgentCronScheduleKind; + expression: string; + intervalMs?: number; +} + +export interface AgentCronJob { + id: string; + status: AgentCronJobStatus; + source?: AgentCronJobSource; + activeSessionId: string; + sessionId: string; + sessionFile: string; + cwd: string; + prompt: string; + schedule: AgentCronSchedule; + createdAt: string; + updatedAt: string; + nextRunAt?: string; + lastRunAt?: string; + lastError?: string; + runCount: number; +} + +export interface CreateAgentCronJobInput { + activeSessionId: string; + sessionId: string; + sessionFile: string; + cwd: string; + prompt: string; + scheduleText: string; + source?: AgentCronJobSource; + now?: Date; +} + +export interface AgentCronSchedulerHooks { + runJob: (job: AgentCronJob) => Promise; + now?: () => Date; + onError?: (job: AgentCronJob, error: unknown) => void; +} + +interface CronJobsFile { + jobs?: unknown; +} + +const MAX_TIMEOUT_MS = 2_147_483_647; +const ONE_SECOND_MS = 1000; +const ONE_MINUTE_MS = 60_000; +export const DEFAULT_HEARTBEAT_SCHEDULE = "every 5m"; + +const createHeartbeatSchema = Type.Object( + { + instruction: Type.String({ + description: + "Required instruction to inject into this same session on each heartbeat. Include enough context for the future turn to act safely.", + }), + interval: Type.Optional( + Type.String({ + description: + "Optional heartbeat cadence. Defaults to 'every 5m'. Examples: '30s', 'every 10m', '@hourly', or '*/30 * * * *'.", + }), + ), + }, + { additionalProperties: false }, +); + +const updateHeartbeatSchema = Type.Object( + { + action: Type.Union([Type.Literal("pause"), Type.Literal("resume"), Type.Literal("clear")], { + description: + "Heartbeat lifecycle action. Use 'pause' to stop firing temporarily, 'resume' to continue, or 'clear' to remove it.", + }), + }, + { additionalProperties: false }, +); + +type CreateHeartbeatArgs = Static; +type UpdateHeartbeatArgs = Static; + +export type ParsedHeartbeatCommand = + | { type: "status" } + | { type: "pause" } + | { type: "resume" } + | { type: "clear" } + | { type: "set"; schedule: string; instruction: string }; + +export interface AgentCronToolController { + getHeartbeat(): AgentCronJob | undefined; + createHeartbeat(instruction: string, interval?: string): AgentCronJob; + updateHeartbeat(action: AgentHeartbeatUpdateAction): AgentCronJob | undefined; +} + +export class AgentCronJobStore { + constructor(private readonly filePath: string) {} + + list(): AgentCronJob[] { + return this.readJobs().sort((a, b) => compareOptionalIso(a.nextRunAt, b.nextRunAt)); + } + + create(input: CreateAgentCronJobInput): AgentCronJob { + const now = input.now ?? new Date(); + const prompt = input.prompt.trim(); + if (!prompt) { + throw new Error("Cron job prompt cannot be empty"); + } + const parsed = parseAgentCronSchedule(input.scheduleText, now); + const nowIso = now.toISOString(); + const job: AgentCronJob = { + id: randomUUID(), + status: "active", + source: input.source ?? "cron", + activeSessionId: input.activeSessionId, + sessionId: input.sessionId, + sessionFile: input.sessionFile, + cwd: input.cwd, + prompt, + schedule: parsed.schedule, + createdAt: nowIso, + updatedAt: nowIso, + nextRunAt: parsed.nextRunAt.toISOString(), + runCount: 0, + }; + this.writeJobs([...this.readJobs(), job]); + return job; + } + + getHeartbeat(activeSessionId: string): AgentCronJob | undefined { + return this.readJobs() + .filter((job) => { + return ( + job.activeSessionId === activeSessionId && + job.source === "heartbeat" && + (job.status === "active" || job.status === "paused") + ); + }) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0]; + } + + createHeartbeat(input: CreateAgentCronJobInput): AgentCronJob { + const now = input.now ?? new Date(); + const parsed = parseAgentCronSchedule(input.scheduleText, now); + if (parsed.schedule.kind === "once") { + throw new Error("Heartbeat schedule must be recurring"); + } + const existing = this.readJobs().map((job) => { + if ( + job.activeSessionId === input.activeSessionId && + job.source === "heartbeat" && + (job.status === "active" || job.status === "paused") + ) { + return { ...job, status: "cancelled" as const, nextRunAt: undefined, updatedAt: now.toISOString() }; + } + return job; + }); + const prompt = input.prompt.trim(); + if (!prompt) { + throw new Error("Heartbeat instruction cannot be empty"); + } + const nowIso = now.toISOString(); + const job: AgentCronJob = { + id: randomUUID(), + status: "active", + source: "heartbeat", + activeSessionId: input.activeSessionId, + sessionId: input.sessionId, + sessionFile: input.sessionFile, + cwd: input.cwd, + prompt, + schedule: parsed.schedule, + createdAt: nowIso, + updatedAt: nowIso, + nextRunAt: parsed.nextRunAt.toISOString(), + runCount: 0, + }; + this.writeJobs([...existing, job]); + return job; + } + + pauseHeartbeat(activeSessionId: string, now = new Date()): AgentCronJob | undefined { + let paused: AgentCronJob | undefined; + const current = this.getHeartbeat(activeSessionId); + if (!current) { + return undefined; + } + const jobs = this.readJobs().map((job) => { + if (job.id !== current.id) { + return job; + } + paused = { ...job, status: "paused", nextRunAt: undefined, updatedAt: now.toISOString() }; + return paused; + }); + this.writeJobs(jobs); + return paused; + } + + resumeHeartbeat(activeSessionId: string, now = new Date()): AgentCronJob | undefined { + let resumed: AgentCronJob | undefined; + const current = this.getHeartbeat(activeSessionId); + if (!current) { + return undefined; + } + const nextRunAt = nextRunAtForSchedule(current.schedule, now); + if (!nextRunAt) { + throw new Error("Heartbeat schedule must be recurring"); + } + const jobs = this.readJobs().map((job) => { + if (job.id !== current.id) { + return job; + } + resumed = { ...job, status: "active", nextRunAt: nextRunAt.toISOString(), updatedAt: now.toISOString() }; + return resumed; + }); + this.writeJobs(jobs); + return resumed; + } + + clearHeartbeat(activeSessionId: string, now = new Date()): AgentCronJob | undefined { + let cleared: AgentCronJob | undefined; + const current = this.getHeartbeat(activeSessionId); + if (!current) { + return undefined; + } + const jobs = this.readJobs().map((job) => { + if (job.id !== current.id) { + return job; + } + cleared = { ...job, status: "cancelled", nextRunAt: undefined, updatedAt: now.toISOString() }; + return cleared; + }); + this.writeJobs(jobs); + return cleared; + } + + cancel(id: string, now = new Date()): AgentCronJob | undefined { + let cancelled: AgentCronJob | undefined; + const jobs = this.readJobs().map((job) => { + if (job.id !== id || job.status === "cancelled") { + return job; + } + cancelled = { ...job, status: "cancelled", nextRunAt: undefined, updatedAt: now.toISOString() }; + return cancelled; + }); + if (cancelled) { + this.writeJobs(jobs); + } + return cancelled; + } + + recordRunResult(id: string, result: { now?: Date; error?: unknown }): AgentCronJob | undefined { + const now = result.now ?? new Date(); + let updated: AgentCronJob | undefined; + const jobs = this.readJobs().map((job) => { + if (job.id !== id) { + return job; + } + if (job.status !== "active") { + updated = job; + return job; + } + const lastError = result.error === undefined ? undefined : errorMessage(result.error); + const nextRunAt = + job.schedule.kind === "cron" + ? nextRunAtForSchedule(job.schedule, new Date(now.getTime() + 1)) + : job.schedule.kind === "interval" + ? nextRunAtForSchedule(job.schedule, now) + : undefined; + updated = { + ...job, + status: job.schedule.kind === "once" ? "completed" : "active", + nextRunAt: nextRunAt?.toISOString(), + lastRunAt: now.toISOString(), + lastError, + runCount: job.runCount + 1, + updatedAt: now.toISOString(), + }; + return updated; + }); + if (updated) { + this.writeJobs(jobs); + } + return updated; + } + + due(now = new Date()): AgentCronJob[] { + return this.readJobs().filter((job) => { + return job.status === "active" && job.nextRunAt !== undefined && Date.parse(job.nextRunAt) <= now.getTime(); + }); + } + + nextActiveRunAt(): Date | undefined { + const times = this.readJobs() + .filter((job) => job.status === "active" && job.nextRunAt !== undefined) + .map((job) => new Date(job.nextRunAt!)) + .filter((date) => Number.isFinite(date.getTime())) + .sort((a, b) => a.getTime() - b.getTime()); + return times[0]; + } + + private readJobs(): AgentCronJob[] { + if (!existsSync(this.filePath)) { + return []; + } + const parsed = JSON.parse(readFileSync(this.filePath, "utf-8")) as CronJobsFile; + if (!Array.isArray(parsed.jobs)) { + return []; + } + return parsed.jobs.filter(isAgentCronJob); + } + + private writeJobs(jobs: readonly AgentCronJob[]): void { + mkdirSync(dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.tmp`; + writeFileSync(tempPath, `${JSON.stringify({ jobs }, null, 2)}\n`, "utf-8"); + renameSync(tempPath, this.filePath); + } +} + +export class AgentCronScheduler { + private timer: ReturnType | undefined; + private running = false; + private stopped = true; + + constructor( + private readonly store: AgentCronJobStore, + private readonly hooks: AgentCronSchedulerHooks, + ) {} + + start(): void { + this.stopped = false; + this.scheduleNext(); + } + + stop(): void { + this.stopped = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + } + + wake(): void { + if (this.stopped) { + return; + } + this.scheduleNext(0); + } + + async runDue(now = this.now()): Promise { + if (this.running) { + return 0; + } + this.running = true; + let handled = 0; + try { + for (const job of this.store.due(now)) { + handled++; + let error: unknown; + try { + await this.hooks.runJob(job); + } catch (runError) { + error = runError; + this.hooks.onError?.(job, runError); + } + this.store.recordRunResult(job.id, { now: this.now(), error }); + } + } finally { + this.running = false; + if (!this.stopped) { + this.scheduleNext(); + } + } + return handled; + } + + private scheduleNext(delayMs?: number): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + const now = this.now(); + const nextDelay = + delayMs ?? + (() => { + const next = this.store.nextActiveRunAt(); + if (!next) { + return undefined; + } + return Math.max(0, next.getTime() - now.getTime()); + })(); + if (nextDelay === undefined) { + return; + } + this.timer = setTimeout( + () => { + void this.runDue(); + }, + Math.min(nextDelay, MAX_TIMEOUT_MS), + ); + } + + private now(): Date { + return this.hooks.now?.() ?? new Date(); + } +} + +export function parseAgentCronSchedule( + input: string, + now = new Date(), +): { schedule: AgentCronSchedule; nextRunAt: Date } { + const text = stripMatchingQuotes(input.trim()); + if (!text) { + throw new Error("Cron schedule cannot be empty"); + } + + const inMatch = /^in\s+(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i.exec(text); + if (inMatch) { + const amount = Number.parseInt(inMatch[1]!, 10); + const unit = inMatch[2]!.toLowerCase(); + const multiplier = unit.startsWith("m") + ? ONE_MINUTE_MS + : unit.startsWith("h") + ? 60 * ONE_MINUTE_MS + : 24 * 60 * ONE_MINUTE_MS; + return { + schedule: { kind: "once", expression: text }, + nextRunAt: new Date(now.getTime() + amount * multiplier), + }; + } + + const everyMatch = + /^(?:every|each)\s+(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)$/i.exec( + text, + ); + if (everyMatch) { + const amount = Number.parseInt(everyMatch[1]!, 10); + const unit = everyMatch[2]!.toLowerCase(); + const multiplier = unit.startsWith("s") + ? ONE_SECOND_MS + : unit.startsWith("m") + ? ONE_MINUTE_MS + : 60 * ONE_MINUTE_MS; + const intervalMs = amount * multiplier; + if (intervalMs < 10 * ONE_SECOND_MS) { + throw new Error("Recurring interval must be at least 10 seconds"); + } + return { + schedule: { kind: "interval", expression: text, intervalMs }, + nextRunAt: new Date(now.getTime() + intervalMs), + }; + } + + if (text.toLowerCase().startsWith("at ")) { + const when = new Date(text.slice(3).trim()); + if (!Number.isFinite(when.getTime())) { + throw new Error("Invalid one-shot schedule. Use: at "); + } + if (when.getTime() <= now.getTime()) { + throw new Error("One-shot schedule must be in the future"); + } + return { schedule: { kind: "once", expression: text }, nextRunAt: when }; + } + + const expression = normalizeCronAlias(text); + const nextRunAt = nextCronRunAfter(expression, now); + return { schedule: { kind: "cron", expression }, nextRunAt }; +} + +export function normalizeHeartbeatSchedule(input: string | undefined): string { + const text = input?.trim(); + if (!text) { + return DEFAULT_HEARTBEAT_SCHEDULE; + } + if (/^\d+\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)$/i.test(text)) { + return `every ${text}`; + } + return text; +} + +export function parseHeartbeatCommand(input: string): ParsedHeartbeatCommand { + const text = input.replace(/^\/heartbeat\b/, "").trim(); + if (!text || text === "status") { + return { type: "status" }; + } + if (text === "pause") { + return { type: "pause" }; + } + if (text === "resume") { + return { type: "resume" }; + } + if (text === "clear" || text === "stop") { + return { type: "clear" }; + } + + const option = consumeEveryOption(text); + if (option) { + if (!option.rest) { + throw new Error("Usage: /heartbeat [--every ] "); + } + return { + type: "set", + schedule: normalizeHeartbeatSchedule(option.interval), + instruction: option.rest, + }; + } + + const leadingSchedule = consumeLeadingEverySchedule(text); + if (leadingSchedule) { + if (!leadingSchedule.rest) { + throw new Error("Usage: /heartbeat [--every ] "); + } + return { + type: "set", + schedule: normalizeHeartbeatSchedule(leadingSchedule.interval), + instruction: leadingSchedule.rest, + }; + } + + return { type: "set", schedule: DEFAULT_HEARTBEAT_SCHEDULE, instruction: text }; +} + +export function nextRunAtForSchedule(schedule: AgentCronSchedule, after: Date): Date | undefined { + if (schedule.kind === "once") { + return undefined; + } + if (schedule.kind === "interval") { + if (!schedule.intervalMs || schedule.intervalMs <= 0) { + throw new Error(`Invalid interval schedule: ${schedule.expression}`); + } + return new Date(after.getTime() + schedule.intervalMs); + } + return nextCronRunAfter(schedule.expression, after); +} + +export function formatAgentCronJob(job: AgentCronJob): string { + const next = job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : "-"; + const last = job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "-"; + const preview = job.prompt.replace(/\s+/g, " ").slice(0, 80); + const error = job.lastError ? ` error=${job.lastError}` : ""; + return `${job.id} ${job.status} next=${next} last=${last} runs=${job.runCount} schedule="${job.schedule.expression}" prompt="${preview}"${error}`; +} + +export function createAgentHeartbeatToolDefinitions(controller: AgentCronToolController): ToolDefinition[] { + return [ + { + name: "get_heartbeat", + label: "Get Heartbeat", + description: "Get the persistent heartbeat configured for this daemon-backed session, if one exists.", + promptGuidelines: [ + "Use get_heartbeat to inspect the current heartbeat before changing it, or when the user asks about heartbeat status.", + ], + parameters: Type.Object({}, { additionalProperties: false }), + execute: async () => { + const job = controller.getHeartbeat(); + return { + content: [{ type: "text", text: JSON.stringify({ heartbeat: job ?? null }, null, 2) }], + details: job ?? null, + }; + }, + }, + { + name: "create_heartbeat", + label: "Create Heartbeat", + description: + "Create or replace the single persistent heartbeat for this same daemon-backed session. Use this only when the user explicitly asks for a recurring check-in, reminder, heartbeat, or continuation.", + promptGuidelines: [ + "Use create_heartbeat when the user explicitly asks this session to keep checking in or continue itself on a cadence.", + "Do not create heartbeats on your own initiative. If the requested instruction is ambiguous, ask a concise follow-up.", + "The interval defaults to every 5 minutes when the user does not specify one.", + ], + parameters: createHeartbeatSchema, + execute: async (_toolCallId: string, params: CreateHeartbeatArgs) => { + const job = controller.createHeartbeat(params.instruction, params.interval); + return { + content: [{ type: "text", text: JSON.stringify({ heartbeat: job }, null, 2) }], + details: job, + }; + }, + }, + { + name: "update_heartbeat", + label: "Update Heartbeat", + description: + "Pause, resume, or clear the persistent heartbeat for this daemon-backed session. Use this only when the user explicitly asks to change heartbeat lifecycle state.", + promptGuidelines: [ + "Use update_heartbeat only when the user explicitly asks to pause, resume, stop, or clear the heartbeat.", + ], + parameters: updateHeartbeatSchema, + execute: async (_toolCallId: string, params: UpdateHeartbeatArgs) => { + const job = controller.updateHeartbeat(params.action); + return { + content: [{ type: "text", text: JSON.stringify({ heartbeat: job ?? null }, null, 2) }], + details: job ?? null, + }; + }, + }, + ]; +} + +function consumeEveryOption(text: string): { interval: string; rest: string } | undefined { + const match = + /^--every(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\d+\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours))|(\S+))(?:\s+|$)([\s\S]*)$/i.exec( + text, + ); + if (!match) { + return undefined; + } + return { + interval: match[1] ?? match[2] ?? match[3] ?? match[4] ?? "", + rest: match[5]?.trim() ?? "", + }; +} + +function consumeLeadingEverySchedule(text: string): { interval: string; rest: string } | undefined { + const match = + /^(every|each)\s+\d+\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\b/i.exec(text); + if (!match) { + return undefined; + } + return { + interval: match[0], + rest: text + .slice(match[0].length) + .trim() + .replace(/^--\s*/, "") + .trim(), + }; +} + +function nextCronRunAfter(expression: string, after: Date): Date { + const fields = parseCronExpression(expression); + const candidate = new Date(after.getTime()); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + const deadline = candidate.getTime() + 366 * 24 * 60 * ONE_MINUTE_MS; + while (candidate.getTime() <= deadline) { + if (matchesCronFields(candidate, fields)) { + return candidate; + } + candidate.setMinutes(candidate.getMinutes() + 1); + } + throw new Error(`Cron schedule did not match within one year: ${expression}`); +} + +function parseCronExpression(expression: string): CronFields { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error( + "Unsupported cron schedule. Use 'in 10m', 'at ', @hourly, or five fields: minute hour day month weekday", + ); + } + return { + minute: parseCronField(parts[0]!, 0, 59), + hour: parseCronField(parts[1]!, 0, 23), + dayOfMonth: parseCronField(parts[2]!, 1, 31), + month: parseCronField(parts[3]!, 1, 12), + dayOfWeek: parseCronField(parts[4]!, 0, 7), + }; +} + +interface CronFields { + minute: Set; + hour: Set; + dayOfMonth: Set; + month: Set; + dayOfWeek: Set; +} + +function parseCronField(field: string, min: number, max: number): Set { + const values = new Set(); + for (const part of field.split(",")) { + if (!part) { + throw new Error(`Invalid cron field: ${field}`); + } + const [rangeText, stepText] = part.split("/"); + const step = stepText === undefined ? 1 : parseCronNumber(stepText, 1, max); + let start: number; + let end: number; + if (rangeText === "*") { + start = min; + end = max; + } else if (rangeText?.includes("-")) { + const [startText, endText] = rangeText.split("-"); + start = parseCronNumber(startText, min, max); + end = parseCronNumber(endText, min, max); + if (start > end) { + throw new Error(`Invalid cron range: ${rangeText}`); + } + } else { + start = parseCronNumber(rangeText, min, max); + end = start; + } + for (let value = start; value <= end; value += step) { + values.add(value); + } + } + return values; +} + +function parseCronNumber(value: string | undefined, min: number, max: number): number { + if (!value || !/^\d+$/.test(value)) { + throw new Error(`Invalid cron number: ${value ?? ""}`); + } + const parsed = Number.parseInt(value, 10); + if (parsed < min || parsed > max) { + throw new Error(`Cron number out of range: ${value}`); + } + return parsed; +} + +function matchesCronFields(date: Date, fields: CronFields): boolean { + const day = date.getDay(); + const dayMatches = fields.dayOfWeek.has(day) || (day === 0 && fields.dayOfWeek.has(7)); + return ( + fields.minute.has(date.getMinutes()) && + fields.hour.has(date.getHours()) && + fields.dayOfMonth.has(date.getDate()) && + fields.month.has(date.getMonth() + 1) && + dayMatches + ); +} + +function normalizeCronAlias(text: string): string { + switch (text) { + case "@hourly": + return "0 * * * *"; + case "@daily": + return "0 0 * * *"; + case "@weekly": + return "0 0 * * 0"; + case "@monthly": + return "0 0 1 * *"; + default: + return text; + } +} + +function stripMatchingQuotes(value: string): string { + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) + ) { + return value.slice(1, -1); + } + return value; +} + +function compareOptionalIso(left: string | undefined, right: string | undefined): number { + if (left === right) { + return 0; + } + if (left === undefined) { + return 1; + } + if (right === undefined) { + return -1; + } + return Date.parse(left) - Date.parse(right); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAgentCronJob(value: unknown): value is AgentCronJob { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + (candidate.status === "active" || + candidate.status === "paused" || + candidate.status === "completed" || + candidate.status === "cancelled") && + (candidate.source === undefined || candidate.source === "cron" || candidate.source === "heartbeat") && + typeof candidate.activeSessionId === "string" && + typeof candidate.sessionId === "string" && + typeof candidate.sessionFile === "string" && + typeof candidate.cwd === "string" && + typeof candidate.prompt === "string" && + typeof candidate.schedule === "object" && + candidate.schedule !== null && + (candidate.schedule.kind === "once" || + candidate.schedule.kind === "cron" || + (candidate.schedule.kind === "interval" && + typeof candidate.schedule.intervalMs === "number" && + candidate.schedule.intervalMs > 0)) && + typeof candidate.schedule.expression === "string" && + typeof candidate.createdAt === "string" && + typeof candidate.updatedAt === "string" && + typeof candidate.runCount === "number" + ); +} diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index abba2c79a2..6d108267f5 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -45,6 +45,11 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ }, { name: "refine", description: "Refine editable harness prompt notes, skills, subagents, and memory" }, { name: "goal", description: "Set or view a persistent goal; supports pause, resume, and clear" }, + { + name: "heartbeat", + description: "Set or view a persistent heartbeat; supports pause, resume, and clear", + argumentHint: "[--every ] ", + }, { name: "resume", description: "Resume a different session" }, { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" }, { name: "quit", description: `Quit ${APP_NAME}` }, diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 79cbcdb43f..258c2b07ce 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -3,6 +3,7 @@ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core" import type { ImageContent, Transport } from "@earendil-works/pi-ai"; import type { CompactionResult } from "../../core/compaction/index.js"; import type { ContextTreeNode } from "../../core/context-tree.js"; +import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import type { SessionStats } from "../../core/session-stats.js"; @@ -300,6 +301,57 @@ export class DaemonAgentConnection implements AgentConnection { }); } + async listCronJobs(options: { includeInactive?: boolean } = {}): Promise { + const data = await this.requestData<{ jobs: AgentCronJob[] }>({ + type: "cron_list", + activeSessionId: this.activeSessionId, + includeInactive: options.includeInactive, + }); + return data.jobs; + } + + async addCronJob(schedule: string, prompt: string): Promise { + const data = await this.requestData<{ job: AgentCronJob }>({ + type: "cron_add", + activeSessionId: this.activeSessionId, + schedule, + prompt, + }); + return data.job; + } + + async cancelCronJob(jobId: string): Promise { + const data = await this.requestData<{ job: AgentCronJob }>({ type: "cron_cancel", jobId }); + return data.job; + } + + async getHeartbeat(): Promise { + const data = await this.requestData<{ heartbeat?: AgentCronJob | null }>({ + type: "heartbeat_get", + activeSessionId: this.activeSessionId, + }); + return data.heartbeat ?? undefined; + } + + async setHeartbeat(schedule: string, instruction: string): Promise { + const data = await this.requestData<{ heartbeat: AgentCronJob }>({ + type: "heartbeat_set", + activeSessionId: this.activeSessionId, + schedule, + prompt: instruction, + }); + return data.heartbeat; + } + + async updateHeartbeat(action: AgentHeartbeatUpdateAction): Promise { + const data = await this.requestData<{ heartbeat?: AgentCronJob | null }>({ + type: "heartbeat_update", + activeSessionId: this.activeSessionId, + action, + }); + return data.heartbeat ?? undefined; + } + async getUserMessagesForForking(): Promise { const data = await this.requestData<{ messages: AgentConnectionUserMessage[] }>({ type: "get_user_messages_for_forking", @@ -766,6 +818,7 @@ function invalidatesCachedSnapshot(commandType: DaemonCommandBody["type"]): bool case "get_resource_snapshot": case "get_available_models": case "get_queue": + case "cron_list": case "get_session_context": case "get_session_tree": case "get_user_messages_for_forking": diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index ae367469dd..8c79731872 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -4,6 +4,7 @@ import type { ImageContent, Transport } from "@earendil-works/pi-ai"; import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js"; import type { CompactionResult } from "../../core/compaction/index.js"; import type { ContextTreeNode } from "../../core/context-tree.js"; +import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import { type DeleteSessionFileResult, deleteSessionFile } from "../../core/session-file-actions.js"; import { SessionManager } from "../../core/session-manager.js"; @@ -151,6 +152,30 @@ export class InProcessAgentConnection implements AgentConnection { return this.session.clearQueue(); } + async listCronJobs(_options: { includeInactive?: boolean } = {}): Promise { + return []; + } + + async addCronJob(_schedule: string, _prompt: string): Promise { + throw new Error("Cron jobs require daemon mode"); + } + + async cancelCronJob(_jobId: string): Promise { + throw new Error("Cron jobs require daemon mode"); + } + + async getHeartbeat(): Promise { + return undefined; + } + + async setHeartbeat(_schedule: string, _instruction: string): Promise { + throw new Error("Heartbeats require daemon mode"); + } + + async updateHeartbeat(_action: AgentHeartbeatUpdateAction): Promise { + throw new Error("Heartbeats require daemon mode"); + } + async getUserMessagesForForking(): Promise { return this.session.getUserMessagesForForking(); } diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 07a06a2acf..52763ed2ae 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -11,6 +11,7 @@ import type { } from "@earendil-works/pi-ai"; import type { CompactionResult } from "../../core/compaction/index.js"; import type { ContextTreeNode } from "../../core/context-tree.js"; +import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js"; import type { GoalState } from "../../core/goals.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; @@ -522,6 +523,12 @@ export interface AgentConnection { ): Promise; getQueue(): Promise; clearQueue(): Promise; + listCronJobs(options?: { includeInactive?: boolean }): Promise; + addCronJob(schedule: string, prompt: string): Promise; + cancelCronJob(jobId: string): Promise; + getHeartbeat(): Promise; + setHeartbeat(schedule: string, instruction: string): Promise; + updateHeartbeat(action: AgentHeartbeatUpdateAction): Promise; getUserMessagesForForking(): Promise; getLastAssistantText(): Promise; getToolDefinition(name: string): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 9f88b4ab0d..57db10aec5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -8,13 +8,22 @@ import { createServer, type Server, type Socket } from "node:net"; import { resolve } from "node:path"; -import { VERSION } from "../../config.js"; +import { getCronJobsPath, VERSION } from "../../config.js"; import { type AgentSessionRuntimeConfig, mergeAgentSessionRuntimeConfig } from "../../core/agent-session-config.js"; import { AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime, } from "../../core/agent-session-runtime.js"; +import { + type AgentCronJob, + AgentCronJobStore, + AgentCronScheduler, + type AgentHeartbeatUpdateAction, + createAgentHeartbeatToolDefinitions, + DEFAULT_HEARTBEAT_SCHEDULE, + normalizeHeartbeatSchedule, +} from "../../core/cron-jobs.js"; import type { CreateRlmSubagentRuntimeOptions, RlmSubagentRuntime, @@ -100,6 +109,12 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "get_available_models", "get_queue", "clear_queue", + "cron_list", + "cron_add", + "cron_cancel", + "heartbeat_get", + "heartbeat_set", + "heartbeat_update", "set_model", "cycle_model", "set_scoped_models", @@ -159,11 +174,24 @@ class AgentDaemon { private readonly sessions = new Map(); private readonly closingSessions = new Map>(); private readonly signalCleanupHandlers: Array<() => void> = []; + private readonly cronStore: AgentCronJobStore; + private readonly cronScheduler: AgentCronScheduler; constructor( private readonly socketPath: string, private readonly options: DaemonModeOptions, - ) {} + ) { + if (!options.defaultSessionConfig.agentDir) { + throw new Error("Daemon config is missing agentDir"); + } + this.cronStore = new AgentCronJobStore(getCronJobsPath(options.defaultSessionConfig.agentDir)); + this.cronScheduler = new AgentCronScheduler(this.cronStore, { + runJob: (job) => this.runCronJob(job), + onError: (job, error) => { + console.error(`Cron job ${job.id} failed: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + } async start(): Promise { await prepareDaemonSocketPath(this.socketPath); @@ -202,6 +230,7 @@ class AgentDaemon { this.registerSignalHandlers(); console.error(`Prime Agent daemon listening on ${this.socketPath}`); void this.restoreActiveSessions(); + this.cronScheduler.start(); } /** @@ -318,13 +347,106 @@ class AgentDaemon { // visible in session lists again. sessionManager.appendSessionState({ status: "sleep" }); } + let stateRef: ActiveSessionState | undefined; const runtime = await createAgentSessionRuntime(this.options.createRuntime, { cwd: sessionManager.getCwd(), agentDir: config.agentDir, sessionManager, sessionConfig: config, + sessionOptions: { + customTools: [ + ...createAgentHeartbeatToolDefinitions({ + getHeartbeat: () => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.cronStore.getHeartbeat(stateRef.activeSessionId); + }, + createHeartbeat: (instruction, interval) => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.createHeartbeatForState(stateRef, interval ?? DEFAULT_HEARTBEAT_SCHEDULE, instruction); + }, + updateHeartbeat: (action) => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.updateHeartbeatForState(stateRef, action); + }, + }), + ], + }, + }); + const state = await this.addRuntime(runtime, command.name); + stateRef = state; + return state; + } + + private async runCronJob(job: AgentCronJob): Promise { + const state = await this.getOrCreateCronJobSession(job); + await state.runtime.session.prompt(job.prompt, { + streamingBehavior: state.runtime.session.isStreaming ? "followUp" : undefined, + source: "rpc", + }); + } + + private createCronJobForState(state: ActiveSessionState, schedule: string, prompt: string): AgentCronJob { + const session = state.runtime.session; + const sessionFile = session.sessionFile; + if (!sessionFile) { + throw new Error("Heartbeats require a persisted session file"); + } + const job = this.cronStore.create({ + activeSessionId: state.activeSessionId, + sessionId: session.sessionId, + sessionFile, + cwd: state.runtime.cwd, + scheduleText: schedule, + prompt, }); - return this.addRuntime(runtime, command.name); + this.cronScheduler.wake(); + return job; + } + + private createHeartbeatForState(state: ActiveSessionState, schedule: string, instruction: string): AgentCronJob { + const session = state.runtime.session; + const sessionFile = session.sessionFile; + if (!sessionFile) { + throw new Error("Heartbeats require a persisted session file"); + } + const job = this.cronStore.createHeartbeat({ + activeSessionId: state.activeSessionId, + sessionId: session.sessionId, + sessionFile, + cwd: state.runtime.cwd, + scheduleText: normalizeHeartbeatSchedule(schedule), + prompt: instruction, + }); + this.cronScheduler.wake(); + return job; + } + + private updateHeartbeatForState( + state: ActiveSessionState, + action: AgentHeartbeatUpdateAction, + ): AgentCronJob | undefined { + const job = + action === "pause" + ? this.cronStore.pauseHeartbeat(state.activeSessionId) + : action === "resume" + ? this.cronStore.resumeHeartbeat(state.activeSessionId) + : this.cronStore.clearHeartbeat(state.activeSessionId); + this.cronScheduler.wake(); + return job; + } + + private async getOrCreateCronJobSession(job: AgentCronJob): Promise { + const current = this.sessions.get(job.activeSessionId) ?? this.findSessionBySessionFile(job.sessionFile); + if (current) { + return current; + } + return this.createRuntime({ type: "create", sessionPath: job.sessionFile }); } private findSessionBySessionFile(sessionFile: string | undefined): ActiveSessionState | undefined { @@ -389,6 +511,7 @@ class AgentDaemon { if (options.parentSession.sessionFile) { sessionManager.newSession({ parentSession: options.parentSession.sessionFile }); } + let stateRef: ActiveSessionState | undefined; const runtime = await createAgentSessionRuntime(this.options.createRuntime, { cwd: sessionManager.getCwd(), agentDir: parentState.runtime.services.agentDir, @@ -401,7 +524,29 @@ class AgentDaemon { scopedModels: options.scopedModels, initialActiveToolNames: options.activeToolNames, allowedToolNames: options.allowedToolNames, - customTools: options.customTools, + customTools: [ + ...(options.customTools ?? []), + ...createAgentHeartbeatToolDefinitions({ + getHeartbeat: () => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.cronStore.getHeartbeat(stateRef.activeSessionId); + }, + createHeartbeat: (instruction, interval) => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.createHeartbeatForState(stateRef, interval ?? DEFAULT_HEARTBEAT_SCHEDULE, instruction); + }, + updateHeartbeat: (action) => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.updateHeartbeatForState(stateRef, action); + }, + }), + ], includeGoals: options.includeGoals, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, @@ -420,7 +565,8 @@ class AgentDaemon { sessionDir: options.sessionDir, }, }); - await this.addRuntime(runtime); + const state = await this.addRuntime(runtime); + stateRef = state; return runtime; } @@ -780,6 +926,51 @@ class AgentDaemon { return success(command.id, "clear_queue", state.runtime.session.clearQueue()); } + case "cron_list": { + const jobs = this.cronStore.list().filter((job) => { + if (!command.includeInactive && job.status !== "active") { + return false; + } + if (command.activeSessionId && job.activeSessionId !== command.activeSessionId) { + return false; + } + return true; + }); + return success(command.id, "cron_list", { jobs }); + } + + case "cron_add": { + const state = this.getSessionState(command.activeSessionId); + const job = this.createCronJobForState(state, command.schedule, command.prompt); + return success(command.id, "cron_add", { job }); + } + + case "cron_cancel": { + const job = this.cronStore.cancel(command.jobId); + if (!job) { + throw new Error(`No cron job found: ${command.jobId}`); + } + this.cronScheduler.wake(); + return success(command.id, "cron_cancel", { job }); + } + + case "heartbeat_get": { + const heartbeat = this.cronStore.getHeartbeat(command.activeSessionId); + return success(command.id, "heartbeat_get", { heartbeat: heartbeat ?? null }); + } + + case "heartbeat_set": { + const state = this.getSessionState(command.activeSessionId); + const heartbeat = this.createHeartbeatForState(state, command.schedule, command.prompt); + return success(command.id, "heartbeat_set", { heartbeat }); + } + + case "heartbeat_update": { + const state = this.getSessionState(command.activeSessionId); + const heartbeat = this.updateHeartbeatForState(state, command.action); + return success(command.id, "heartbeat_update", { heartbeat: heartbeat ?? null }); + } + case "set_model": { const state = this.getSessionState(command.activeSessionId); const session = state.runtime.session; @@ -1209,6 +1400,7 @@ class AgentDaemon { for (const cleanup of this.signalCleanupHandlers) { cleanup(); } + this.cronScheduler.stop(); for (const state of [...this.sessions.values()]) { await this.closeSession(state, "shutdown"); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index e803c6961d..3ece506376 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -1,6 +1,7 @@ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, Transport } from "@earendil-works/pi-ai"; import type { AgentSessionRuntimeConfig } from "../../core/agent-session-config.js"; +import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js"; import type { SessionCwdIssue } from "../../core/session-cwd.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import type { @@ -193,6 +194,12 @@ export type DaemonCommand = | { id?: string; type: "get_available_models"; activeSessionId: string } | { id?: string; type: "get_queue"; activeSessionId: string } | { id?: string; type: "clear_queue"; activeSessionId: string } + | { id?: string; type: "cron_list"; activeSessionId?: string; includeInactive?: boolean } + | { id?: string; type: "cron_add"; activeSessionId: string; schedule: string; prompt: string } + | { id?: string; type: "cron_cancel"; jobId: string } + | { id?: string; type: "heartbeat_get"; activeSessionId: string } + | { id?: string; type: "heartbeat_set"; activeSessionId: string; schedule: string; prompt: string } + | { id?: string; type: "heartbeat_update"; activeSessionId: string; action: AgentHeartbeatUpdateAction } | { id?: string; type: "set_model"; activeSessionId: string; provider: string; modelId: string } | { id?: string; type: "cycle_model"; activeSessionId: string; direction?: "forward" | "backward" } | { id?: string; type: "set_scoped_models"; activeSessionId: string; scopedModels: AgentConnectionScopedModel[] } @@ -302,6 +309,8 @@ export type DaemonDeleteSavedSessionResult = DeleteSessionFileResult; export type DaemonResourceSnapshot = AgentConnectionResourceSnapshot; +export type DaemonCronJob = AgentCronJob; + export type DaemonOutbound = | DaemonResponse | DaemonRequestProgress diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 625235577f..bf1b7c7dea 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -41,6 +41,7 @@ import { import { spawn, spawnSync } from "child_process"; import { APP_TITLE, getAgentDir, getDebugLogPath, getShareViewerUrl, VERSION } from "../../config.js"; import { isNoModelsAvailableMessage } from "../../core/auth-guidance.js"; +import { type AgentCronJob, parseHeartbeatCommand } from "../../core/cron-jobs.js"; import type { AutocompleteProviderFactory, EditorFactory, @@ -3187,6 +3188,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/heartbeat" || text.startsWith("/heartbeat ")) { + await this.handleHeartbeatCommand(text); + this.editor.setText(""); + return; + } if (text === "/changelog") { this.handleChangelogCommand(); this.editor.setText(""); @@ -6503,6 +6509,78 @@ export class InteractiveMode { this.ui.requestRender(); } + private async handleHeartbeatCommand(text: string): Promise { + try { + const command = parseHeartbeatCommand(text); + switch (command.type) { + case "status": { + const heartbeat = await this.agentConnection.getHeartbeat(); + this.showHeartbeat(heartbeat); + return; + } + case "set": { + const heartbeat = await this.agentConnection.setHeartbeat(command.schedule, command.instruction); + this.showStatus(`Heartbeat set\nNext run: ${heartbeat.nextRunAt ?? "-"}`); + return; + } + case "pause": { + const heartbeat = await this.agentConnection.updateHeartbeat("pause"); + if (!heartbeat) { + this.showStatus("No active heartbeat"); + return; + } + this.showStatus("Heartbeat paused"); + return; + } + case "resume": { + const heartbeat = await this.agentConnection.updateHeartbeat("resume"); + if (!heartbeat) { + this.showStatus("No active heartbeat"); + return; + } + this.showStatus(`Heartbeat resumed\nNext run: ${heartbeat.nextRunAt ?? "-"}`); + return; + } + case "clear": { + const heartbeat = await this.agentConnection.updateHeartbeat("clear"); + if (!heartbeat) { + this.showStatus("No active heartbeat"); + return; + } + this.showStatus("Heartbeat cleared"); + return; + } + } + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private showHeartbeat(job: AgentCronJob | undefined): void { + if (!job) { + this.showStatus("No active heartbeat"); + return; + } + const next = job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : "-"; + const last = job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "-"; + const lines = [ + theme.bold("Heartbeat"), + "", + `${theme.fg("dim", "Status:")} ${job.status}`, + `${theme.fg("dim", "Every:")} ${job.schedule.expression}`, + `${theme.fg("dim", "Instruction:")} ${job.prompt}`, + `${theme.fg("dim", "Next:")} ${next}`, + `${theme.fg("dim", "Last:")} ${last}`, + `${theme.fg("dim", "Runs:")} ${job.runCount}`, + ]; + if (job.lastError) { + lines.push(`${theme.fg("dim", "Error:")} ${job.lastError}`); + } + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(lines.join("\n"), 1, 0)); + this.ui.requestRender(); + } + private handleChangelogCommand(): void { const changelogPath = getChangelogPath(); const allEntries = parseChangelog(changelogPath); diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts new file mode 100644 index 0000000000..9b7f7db749 --- /dev/null +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -0,0 +1,384 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + AgentCronJobStore, + AgentCronScheduler, + createAgentHeartbeatToolDefinitions, + parseAgentCronSchedule, + parseHeartbeatCommand, +} from "../src/core/cron-jobs.js"; + +const start = new Date("2026-01-01T12:34:00.000Z"); + +describe("parseAgentCronSchedule", () => { + it("parses one-shot relative schedules", () => { + const parsed = parseAgentCronSchedule("in 15m", start); + + expect(parsed.schedule).toEqual({ kind: "once", expression: "in 15m" }); + expect(parsed.nextRunAt.toISOString()).toBe("2026-01-01T12:49:00.000Z"); + }); + + it("parses cron aliases and five-field cron subsets", () => { + expect(parseAgentCronSchedule("@hourly", start).nextRunAt.toISOString()).toBe("2026-01-01T13:00:00.000Z"); + expect(parseAgentCronSchedule("*/30 * * * *", start).nextRunAt.toISOString()).toBe("2026-01-01T13:00:00.000Z"); + }); + + it("parses recurring heartbeat intervals with seconds", () => { + const parsed = parseAgentCronSchedule("every 30s", start); + + expect(parsed.schedule).toEqual({ kind: "interval", expression: "every 30s", intervalMs: 30_000 }); + expect(parsed.nextRunAt.toISOString()).toBe("2026-01-01T12:34:30.000Z"); + }); + + it("rejects unsupported cron syntax", () => { + expect(() => parseAgentCronSchedule("0 9 * * MON", start)).toThrow("Invalid cron number"); + }); +}); + +describe("parseHeartbeatCommand", () => { + it("matches the goal-style status and lifecycle commands", () => { + expect(parseHeartbeatCommand("/heartbeat")).toEqual({ type: "status" }); + expect(parseHeartbeatCommand("/heartbeat status")).toEqual({ type: "status" }); + expect(parseHeartbeatCommand("/heartbeat pause")).toEqual({ type: "pause" }); + expect(parseHeartbeatCommand("/heartbeat resume")).toEqual({ type: "resume" }); + expect(parseHeartbeatCommand("/heartbeat clear")).toEqual({ type: "clear" }); + expect(parseHeartbeatCommand("/heartbeat stop")).toEqual({ type: "clear" }); + }); + + it("defaults new heartbeat instructions to every five minutes", () => { + expect(parseHeartbeatCommand("/heartbeat check on me")).toEqual({ + type: "set", + schedule: "every 5m", + instruction: "check on me", + }); + }); + + it("accepts explicit heartbeat intervals", () => { + expect(parseHeartbeatCommand("/heartbeat --every 30s check on me")).toEqual({ + type: "set", + schedule: "every 30s", + instruction: "check on me", + }); + expect(parseHeartbeatCommand("/heartbeat every 10m -- check status")).toEqual({ + type: "set", + schedule: "every 10m", + instruction: "check status", + }); + }); +}); + +describe("AgentCronJobStore", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("persists, reloads, and cancels jobs", () => { + const storePath = makeStorePath(tempDirs); + const store = new AgentCronJobStore(storePath); + const job = store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1h", + prompt: "check the long run", + now: start, + }); + + expect(job.nextRunAt).toBe("2026-01-01T13:34:00.000Z"); + expect(new AgentCronJobStore(storePath).list()).toMatchObject([ + { + id: job.id, + status: "active", + prompt: "check the long run", + }, + ]); + + const cancelled = store.cancel(job.id, new Date("2026-01-01T12:40:00.000Z")); + + expect(cancelled).toMatchObject({ id: job.id, status: "cancelled" }); + expect(store.list()[0]).toMatchObject({ id: job.id, status: "cancelled" }); + expect(store.list()[0]).not.toHaveProperty("nextRunAt"); + }); + + it("keeps overdue jobs eligible for the scheduler after restart", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1m", + prompt: "check the long run", + now: start, + }); + + expect(store.nextActiveRunAt()?.toISOString()).toBe("2026-01-01T12:35:00.000Z"); + }); + + it("keeps one persistent heartbeat per active session", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const first = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 30s", + prompt: "check on me", + now: start, + }); + const second = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "continue the work", + now: new Date("2026-01-01T12:35:00.000Z"), + }); + + expect(store.getHeartbeat("active-1")).toMatchObject({ id: second.id, prompt: "continue the work" }); + expect(store.list().find((job) => job.id === first.id)).toMatchObject({ status: "cancelled" }); + }); + + it("pauses, resumes, and clears heartbeat state", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const job = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 30s", + prompt: "check on me", + now: start, + }); + + expect(store.pauseHeartbeat("active-1", new Date("2026-01-01T12:34:10.000Z"))).toMatchObject({ + id: job.id, + status: "paused", + }); + expect(store.getHeartbeat("active-1")).not.toHaveProperty("nextRunAt"); + expect(store.resumeHeartbeat("active-1", new Date("2026-01-01T12:35:00.000Z"))).toMatchObject({ + id: job.id, + status: "active", + nextRunAt: "2026-01-01T12:35:30.000Z", + }); + expect(store.clearHeartbeat("active-1", new Date("2026-01-01T12:36:00.000Z"))).toMatchObject({ + id: job.id, + status: "cancelled", + }); + expect(store.getHeartbeat("active-1")).toBeUndefined(); + }); + + it("rejects one-shot heartbeat schedules", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + + expect(() => + store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 5m", + prompt: "check on me", + now: start, + }), + ).toThrow("Heartbeat schedule must be recurring"); + }); +}); + +describe("AgentCronScheduler", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runs due one-shot jobs and marks them completed", async () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const job = store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1m", + prompt: "continue the audit", + now: start, + }); + const prompts: string[] = []; + const scheduler = new AgentCronScheduler(store, { + now: () => new Date("2026-01-01T12:35:00.000Z"), + runJob: async (dueJob) => { + prompts.push(dueJob.prompt); + }, + }); + + await scheduler.runDue(new Date("2026-01-01T12:35:00.000Z")); + + expect(prompts).toEqual(["continue the audit"]); + expect(store.list()[0]).toMatchObject({ + id: job.id, + status: "completed", + runCount: 1, + lastRunAt: "2026-01-01T12:35:00.000Z", + }); + expect(store.list()[0]).not.toHaveProperty("nextRunAt"); + }); + + it("reschedules recurring jobs after each run", async () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "* * * * *", + prompt: "poll status", + now: new Date("2026-01-01T12:34:00.000Z"), + }); + const scheduler = new AgentCronScheduler(store, { + now: () => new Date("2026-01-01T12:35:00.000Z"), + runJob: async () => {}, + }); + + await scheduler.runDue(new Date("2026-01-01T12:35:00.000Z")); + + expect(store.list()[0]).toMatchObject({ + status: "active", + runCount: 1, + lastRunAt: "2026-01-01T12:35:00.000Z", + nextRunAt: "2026-01-01T12:36:00.000Z", + }); + }); + + it("reschedules interval heartbeats after each run", async () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 30s", + prompt: "check on me", + now: start, + }); + const scheduler = new AgentCronScheduler(store, { + now: () => new Date("2026-01-01T12:34:30.000Z"), + runJob: async () => {}, + }); + + await scheduler.runDue(new Date("2026-01-01T12:34:30.000Z")); + + expect(store.list()[0]).toMatchObject({ + status: "active", + runCount: 1, + lastRunAt: "2026-01-01T12:34:30.000Z", + nextRunAt: "2026-01-01T12:35:00.000Z", + }); + }); +}); + +describe("createAgentHeartbeatToolDefinitions", () => { + it("lets the model create a heartbeat when explicitly requested", async () => { + const tools = createAgentHeartbeatToolDefinitions({ + getHeartbeat: () => undefined, + createHeartbeat: (instruction, interval) => + ({ + id: "job-1", + status: "active", + source: "heartbeat", + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + prompt: instruction, + schedule: { kind: "interval", expression: interval ?? "every 5m", intervalMs: 30_000 }, + createdAt: start.toISOString(), + updatedAt: start.toISOString(), + nextRunAt: "2026-01-01T12:34:30.000Z", + runCount: 0, + }) as const, + updateHeartbeat: () => undefined, + }); + const tool = tools.find((candidate) => candidate.name === "create_heartbeat"); + + expect(tool).toBeDefined(); + + const result = await tool!.execute( + "tool-1", + { interval: "every 30s", instruction: "check on me" }, + undefined, + undefined, + {} as never, + ); + + expect(result.details).toMatchObject({ + id: "job-1", + schedule: { expression: "every 30s" }, + prompt: "check on me", + }); + }); + + it("lets the model inspect and update heartbeat state", async () => { + const tools = createAgentHeartbeatToolDefinitions({ + getHeartbeat: () => + ({ + id: "job-1", + status: "active", + source: "heartbeat", + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + prompt: "check on me", + schedule: { kind: "interval", expression: "every 30s", intervalMs: 30_000 }, + createdAt: start.toISOString(), + updatedAt: start.toISOString(), + nextRunAt: "2026-01-01T12:34:30.000Z", + runCount: 0, + }) as const, + createHeartbeat: () => { + throw new Error("not used"); + }, + updateHeartbeat: (action) => + ({ + id: "job-1", + status: action === "pause" ? "paused" : "cancelled", + source: "heartbeat", + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + prompt: "check on me", + schedule: { kind: "interval", expression: "every 30s", intervalMs: 30_000 }, + createdAt: start.toISOString(), + updatedAt: start.toISOString(), + runCount: 0, + }) as const, + }); + + const getResult = await tools + .find((candidate) => candidate.name === "get_heartbeat")! + .execute("tool-1", {}, undefined, undefined, {} as never); + const updateResult = await tools + .find((candidate) => candidate.name === "update_heartbeat")! + .execute("tool-2", { action: "pause" }, undefined, undefined, {} as never); + + expect(getResult.details).toMatchObject({ id: "job-1", status: "active" }); + expect(updateResult.details).toMatchObject({ id: "job-1", status: "paused" }); + }); +}); + +function makeStorePath(tempDirs: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "prime-agent-cron-")); + tempDirs.push(dir); + return join(dir, "cron-jobs.json"); +} diff --git a/packages/coding-agent/test/daemon-command.test.ts b/packages/coding-agent/test/daemon-command.test.ts index 430d3faef4..8a40811932 100644 --- a/packages/coding-agent/test/daemon-command.test.ts +++ b/packages/coding-agent/test/daemon-command.test.ts @@ -6,6 +6,9 @@ const daemonClientMock = vi.hoisted(() => { type Command = { type: string; name?: string; + activeSessionId?: string; + schedule?: string; + prompt?: string; sessionPath?: string; config?: { extensionFlagValues?: Record }; }; @@ -221,6 +224,30 @@ describe("daemon command", () => { sessionPath: "abc123", }); }); + + it("preserves cron add separator before the scheduled prompt", async () => { + await expect( + handleDaemonCommand([ + "daemon", + "--socket", + "/tmp/prime-agent.sock", + "cron", + "add", + "active-1", + "in 5m", + "--", + "check status", + ]), + ).resolves.toBe(true); + + const client = daemonClientMock.instances[0]; + expect(client?.requests[0]).toEqual({ + type: "cron_add", + activeSessionId: "active-1", + schedule: "in 5m", + prompt: "check status", + }); + }); }); async function flushPromises(): Promise { diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts new file mode 100644 index 0000000000..2d90bd2023 --- /dev/null +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "vitest"; +import { BUILTIN_SLASH_COMMANDS } from "../src/core/slash-commands.js"; + +describe("built-in slash commands", () => { + test("exposes heartbeat without exposing a cron slash command", () => { + const commandNames = BUILTIN_SLASH_COMMANDS.map((command) => command.name); + + expect(commandNames).toContain("heartbeat"); + expect(commandNames).not.toContain("cron"); + }); +}); From c40e97b7eb354034f8d4ee972d463534e499c3d5 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 15 Jun 2026 15:28:01 -0700 Subject: [PATCH 02/17] Add RLM heartbeat skill --- .../skills/rlm-heartbeat/SKILL.md | 41 +++++ .../skills/rlm-heartbeat/pyproject.toml | 17 ++ .../src/rlm_heartbeat/__init__.py | 74 ++++++++ .../src/core/agent-session-services.ts | 3 + .../coding-agent/src/core/agent-session.ts | 122 +++++++++++++ packages/coding-agent/src/core/cron-jobs.ts | 161 +++++++++++++++++- packages/coding-agent/src/core/sdk.ts | 1 + .../src/modes/daemon/daemon-mode.ts | 122 ++++++++++--- .../coding-agent/test/builtin-skills.test.ts | 9 + packages/coding-agent/test/cron-jobs.test.ts | 112 ++++++++++++ .../test/kernel-rlm-heartbeat-skill.test.ts | 148 ++++++++++++++++ 11 files changed, 784 insertions(+), 26 deletions(-) create mode 100644 packages/coding-agent/skills/rlm-heartbeat/SKILL.md create mode 100644 packages/coding-agent/skills/rlm-heartbeat/pyproject.toml create mode 100644 packages/coding-agent/skills/rlm-heartbeat/src/rlm_heartbeat/__init__.py create mode 100644 packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts diff --git a/packages/coding-agent/skills/rlm-heartbeat/SKILL.md b/packages/coding-agent/skills/rlm-heartbeat/SKILL.md new file mode 100644 index 0000000000..d7a6dc23d7 --- /dev/null +++ b/packages/coding-agent/skills/rlm-heartbeat/SKILL.md @@ -0,0 +1,41 @@ +--- +name: rlm-heartbeat +description: Manage internal RLM heartbeats from IPython. Use to schedule recurring self-checks for the current agent session without touching the user's /heartbeat. +--- + +# RLM Heartbeat + +RLM heartbeats are internal recurring prompts for the current agent session. +They are separate from the user's visible `/heartbeat`: this skill cannot read, +replace, pause, resume, or clear that user-level heartbeat. + +Call directly from IPython: + +```python +await rlm_heartbeat.create("check test progress", interval="5m", label="tests") +await rlm_heartbeat.list() +await rlm_heartbeat.update("job-id", status="pause") +await rlm_heartbeat.delete("job-id") +``` + +## API + +- `await rlm_heartbeat.list(include_inactive=False)` — list this session's + internal RLM heartbeats. By default this includes active and paused entries. +- `await rlm_heartbeat.create(instruction, interval=None, label=None)` — create + a recurring heartbeat for this session. The default interval is every 5 + minutes. Multiple RLM heartbeats may run at once; use labels to distinguish + them. +- `await rlm_heartbeat.update(id, instruction=None, interval=None, label=None, + status=None)` — update one RLM heartbeat by id. `status` may be `"pause"` or + `"resume"`. +- `await rlm_heartbeat.delete(id)` — cancel one RLM heartbeat by id. + +## Rules + +- Use this only for agent-internal recurring checks and long-running task + coordination. +- Do not use this skill to satisfy a user's request to configure `/heartbeat`; + that is a separate user-level surface. +- Keep heartbeat instructions specific and actionable so each recurring turn + knows exactly what to inspect or continue. diff --git a/packages/coding-agent/skills/rlm-heartbeat/pyproject.toml b/packages/coding-agent/skills/rlm-heartbeat/pyproject.toml new file mode 100644 index 0000000000..58fd0e4b6e --- /dev/null +++ b/packages/coding-agent/skills/rlm-heartbeat/pyproject.toml @@ -0,0 +1,17 @@ +# Kernel-side package for the bundled RLM heartbeat skill. It only talks to the +# host through rlm.host_request; prime-agent-runtime is always installed in the +# kernel venv before skills, so it is intentionally not declared as a +# dependency (it is not published on PyPI). +[project] +name = "rlm-heartbeat" +version = "0.1.0" +description = "Prime Agent RLM heartbeat skill: internal recurring session checks over the host bridge" +requires-python = ">=3.10" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/rlm_heartbeat"] diff --git a/packages/coding-agent/skills/rlm-heartbeat/src/rlm_heartbeat/__init__.py b/packages/coding-agent/skills/rlm-heartbeat/src/rlm_heartbeat/__init__.py new file mode 100644 index 0000000000..0f946a7d61 --- /dev/null +++ b/packages/coding-agent/skills/rlm-heartbeat/src/rlm_heartbeat/__init__.py @@ -0,0 +1,74 @@ +"""Prime Agent RLM heartbeat skill: internal recurring session checks. + +All heartbeat state lives in the TypeScript host; these functions are thin +typed wrappers over the generic host bridge (`rlm.host_request`). They only +work inside the Prime Agent IPython kernel. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from rlm import host_request + +StatusUpdate = Literal["pause", "resume"] + + +async def list(include_inactive: bool = False) -> dict[str, Any]: + """List internal RLM heartbeats for the current agent session.""" + if not isinstance(include_inactive, bool): + raise TypeError(f"include_inactive must be bool, got {type(include_inactive).__name__}") + return await host_request("rlm_heartbeat.list", {"include_inactive": include_inactive}) + + +async def create(instruction: str, interval: str | None = None, label: str | None = None) -> dict[str, Any]: + """Create an internal recurring heartbeat for the current agent session.""" + if not isinstance(instruction, str): + raise TypeError(f"instruction must be str, got {type(instruction).__name__}") + payload: dict[str, Any] = {"instruction": instruction} + if interval is not None: + if not isinstance(interval, str): + raise TypeError(f"interval must be str or None, got {type(interval).__name__}") + payload["interval"] = interval + if label is not None: + if not isinstance(label, str): + raise TypeError(f"label must be str or None, got {type(label).__name__}") + payload["label"] = label + return await host_request("rlm_heartbeat.create", payload) + + +async def update( + id: str, + instruction: str | None = None, + interval: str | None = None, + label: str | None = None, + status: StatusUpdate | None = None, +) -> dict[str, Any]: + """Update one internal RLM heartbeat for the current agent session.""" + if not isinstance(id, str): + raise TypeError(f"id must be str, got {type(id).__name__}") + payload: dict[str, Any] = {"id": id} + if instruction is not None: + if not isinstance(instruction, str): + raise TypeError(f"instruction must be str or None, got {type(instruction).__name__}") + payload["instruction"] = instruction + if interval is not None: + if not isinstance(interval, str): + raise TypeError(f"interval must be str or None, got {type(interval).__name__}") + payload["interval"] = interval + if label is not None: + if not isinstance(label, str): + raise TypeError(f"label must be str or None, got {type(label).__name__}") + payload["label"] = label + if status is not None: + if status not in {"pause", "resume"}: + raise ValueError('status must be "pause", "resume", or None') + payload["status"] = status + return await host_request("rlm_heartbeat.update", payload) + + +async def delete(id: str) -> dict[str, Any]: + """Cancel one internal RLM heartbeat for the current agent session.""" + if not isinstance(id, str): + raise TypeError(f"id must be str, got {type(id).__name__}") + return await host_request("rlm_heartbeat.delete", {"id": id}) diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 46ed2458fd..4cbfebf8a7 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -3,6 +3,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.js"; import { AuthStorage } from "./auth-storage.js"; +import type { AgentRlmHeartbeatController } from "./cron-jobs.js"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js"; import { ModelRegistry } from "./model-registry.js"; import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js"; @@ -55,6 +56,7 @@ export interface AgentSessionCreationOptions { rlmSessionDir?: string; rlmParentNodeId?: string; subagentRuntimeHost?: SubagentRuntimeHost; + rlmHeartbeatController?: AgentRlmHeartbeatController; prewarmIpythonKernel?: boolean; } @@ -214,6 +216,7 @@ export async function createAgentSessionFromServices( rlmSessionDir: options.rlmSessionDir, rlmParentNodeId: options.rlmParentNodeId, subagentRuntimeHost: options.subagentRuntimeHost, + rlmHeartbeatController: options.rlmHeartbeatController, sessionStartEvent: options.sessionStartEvent, prewarmIpythonKernel: options.prewarmIpythonKernel, }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1d020d1f88..77a99222f3 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -68,6 +68,7 @@ import { loadContextTreeChildFromDisk, loadContextTreeChildrenFromDisk, } from "./context-tree.js"; +import type { AgentCronJob, AgentRlmHeartbeatController, AgentRlmHeartbeatStatusUpdate } from "./cron-jobs.js"; import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; @@ -298,6 +299,11 @@ export interface AgentSessionConfig { * Default: true. */ includeGoals?: boolean; + /** + * Optional host-side controller for the bundled rlm-heartbeat Python skill. + * When omitted, rlm_heartbeat.* host requests are unavailable. + */ + rlmHeartbeatController?: AgentRlmHeartbeatController; /** * Override base tools (useful for custom runtimes). * @@ -684,6 +690,7 @@ export class AgentSession { private _initialActiveToolNames?: string[]; private _allowedToolNames?: Set; private _includeGoals: boolean; + private _rlmHeartbeatController?: AgentRlmHeartbeatController; private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; @@ -727,6 +734,7 @@ export class AgentSession { this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._includeGoals = config.includeGoals ?? true; + this._rlmHeartbeatController = config.rlmHeartbeatController; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; this._rlmDepth = config.rlmDepth ?? parseDepth(process.env.RLM_DEPTH, 0, "RLM_DEPTH"); @@ -1321,6 +1329,90 @@ export class AgentSession { } } + /** + * Handle an rlm_heartbeat.* request from the bundled rlm-heartbeat skill. + * These heartbeats are internal to this active session and never read or + * mutate the user-level /heartbeat. + */ + handleRlmHeartbeatHostRequest(type: string, payload: Record = {}): Record { + const controller = this._rlmHeartbeatController; + if (!controller) { + throw new Error("RLM heartbeat skill is not available in this session"); + } + switch (type) { + case "rlm_heartbeat.list": { + const includeInactive = payload.include_inactive === true || payload.includeInactive === true; + return { + heartbeats: controller + .listRlmHeartbeats({ includeInactive }) + .map((heartbeat) => rlmHeartbeatHostResponse(heartbeat)), + }; + } + case "rlm_heartbeat.create": { + if (typeof payload.instruction !== "string") { + throw new Error("rlm_heartbeat.create instruction must be a string"); + } + if (payload.interval !== undefined && typeof payload.interval !== "string") { + throw new Error("rlm_heartbeat.create interval must be a string when provided"); + } + if (payload.label !== undefined && typeof payload.label !== "string") { + throw new Error("rlm_heartbeat.create label must be a string when provided"); + } + return { + heartbeat: rlmHeartbeatHostResponse( + controller.createRlmHeartbeat({ + instruction: payload.instruction, + interval: payload.interval, + label: payload.label, + }), + ), + }; + } + case "rlm_heartbeat.update": { + if (typeof payload.id !== "string") { + throw new Error("rlm_heartbeat.update id must be a string"); + } + if (payload.instruction !== undefined && typeof payload.instruction !== "string") { + throw new Error("rlm_heartbeat.update instruction must be a string when provided"); + } + if (payload.interval !== undefined && typeof payload.interval !== "string") { + throw new Error("rlm_heartbeat.update interval must be a string when provided"); + } + if (payload.label !== undefined && typeof payload.label !== "string") { + throw new Error("rlm_heartbeat.update label must be a string when provided"); + } + if (payload.status !== undefined && !isRlmHeartbeatStatusUpdate(payload.status)) { + throw new Error('rlm_heartbeat.update status must be "pause" or "resume" when provided'); + } + if ( + payload.instruction === undefined && + payload.interval === undefined && + payload.label === undefined && + payload.status === undefined + ) { + throw new Error("rlm_heartbeat.update requires at least one field to update"); + } + const heartbeat = controller.updateRlmHeartbeat({ + id: payload.id, + instruction: payload.instruction, + interval: payload.interval, + label: payload.label, + status: payload.status, + }); + return { heartbeat: heartbeat ? rlmHeartbeatHostResponse(heartbeat) : null }; + } + case "rlm_heartbeat.delete": { + if (typeof payload.id !== "string") { + throw new Error("rlm_heartbeat.delete id must be a string"); + } + const heartbeat = controller.deleteRlmHeartbeat(payload.id); + return { heartbeat: heartbeat ? rlmHeartbeatHostResponse(heartbeat) : null }; + } + default: + throw new Error(`unknown RLM heartbeat request type "${type}"`); + } + } + private _createGoalFromHost(objective: string, tokenBudget: number | undefined): GoalState { switch (this._goalState.status) { case "active": @@ -3484,6 +3576,16 @@ export class AgentSession { handlers[type] = async (payload) => this.handleGoalHostRequest(type, payload); } } + if (this._rlmHeartbeatController) { + for (const type of [ + "rlm_heartbeat.list", + "rlm_heartbeat.create", + "rlm_heartbeat.update", + "rlm_heartbeat.delete", + ]) { + handlers[type] = async (payload) => this.handleRlmHeartbeatHostRequest(type, payload); + } + } return handlers; } @@ -4908,3 +5010,23 @@ export class AgentSession { return this._extensionRunner; } } + +function isRlmHeartbeatStatusUpdate(value: unknown): value is AgentRlmHeartbeatStatusUpdate { + return value === "pause" || value === "resume"; +} + +function rlmHeartbeatHostResponse(job: AgentCronJob): Record { + return { + id: job.id, + status: job.status, + label: job.label ?? null, + instruction: job.prompt, + schedule: job.schedule, + created_at: job.createdAt, + updated_at: job.updatedAt, + next_run_at: job.nextRunAt ?? null, + last_run_at: job.lastRunAt ?? null, + last_error: job.lastError ?? null, + run_count: job.runCount, + }; +} diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 14e7e2b1e1..0702393660 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -6,8 +6,9 @@ import type { ToolDefinition } from "./extensions/types.js"; export type AgentCronJobStatus = "active" | "paused" | "completed" | "cancelled"; export type AgentCronScheduleKind = "once" | "cron" | "interval"; -export type AgentCronJobSource = "cron" | "heartbeat"; +export type AgentCronJobSource = "cron" | "heartbeat" | "rlm_heartbeat"; export type AgentHeartbeatUpdateAction = "pause" | "resume" | "clear"; +export type AgentRlmHeartbeatStatusUpdate = "pause" | "resume"; export interface AgentCronSchedule { kind: AgentCronScheduleKind; @@ -23,6 +24,7 @@ export interface AgentCronJob { sessionId: string; sessionFile: string; cwd: string; + label?: string; prompt: string; schedule: AgentCronSchedule; createdAt: string; @@ -38,6 +40,7 @@ export interface CreateAgentCronJobInput { sessionId: string; sessionFile: string; cwd: string; + label?: string; prompt: string; scheduleText: string; source?: AgentCronJobSource; @@ -101,6 +104,19 @@ export interface AgentCronToolController { updateHeartbeat(action: AgentHeartbeatUpdateAction): AgentCronJob | undefined; } +export interface AgentRlmHeartbeatController { + listRlmHeartbeats(options?: { includeInactive?: boolean }): AgentCronJob[]; + createRlmHeartbeat(input: { instruction: string; interval?: string; label?: string }): AgentCronJob; + updateRlmHeartbeat(input: { + id: string; + instruction?: string; + interval?: string; + label?: string; + status?: AgentRlmHeartbeatStatusUpdate; + }): AgentCronJob | undefined; + deleteRlmHeartbeat(id: string): AgentCronJob | undefined; +} + export class AgentCronJobStore { constructor(private readonly filePath: string) {} @@ -124,6 +140,7 @@ export class AgentCronJobStore { sessionId: input.sessionId, sessionFile: input.sessionFile, cwd: input.cwd, + label: normalizeOptionalLabel(input.label), prompt, schedule: parsed.schedule, createdAt: nowIso, @@ -176,6 +193,7 @@ export class AgentCronJobStore { sessionId: input.sessionId, sessionFile: input.sessionFile, cwd: input.cwd, + label: normalizeOptionalLabel(input.label), prompt, schedule: parsed.schedule, createdAt: nowIso, @@ -187,6 +205,128 @@ export class AgentCronJobStore { return job; } + listRlmHeartbeats(activeSessionId: string, options: { includeInactive?: boolean } = {}): AgentCronJob[] { + return this.readJobs() + .filter((job) => { + if (job.activeSessionId !== activeSessionId || job.source !== "rlm_heartbeat") { + return false; + } + if (options.includeInactive) { + return true; + } + return job.status === "active" || job.status === "paused"; + }) + .sort((a, b) => compareOptionalIso(a.nextRunAt, b.nextRunAt)); + } + + createRlmHeartbeat(input: CreateAgentCronJobInput): AgentCronJob { + const now = input.now ?? new Date(); + const parsed = parseAgentCronSchedule(input.scheduleText, now); + if (parsed.schedule.kind === "once") { + throw new Error("RLM heartbeat schedule must be recurring"); + } + const prompt = input.prompt.trim(); + if (!prompt) { + throw new Error("RLM heartbeat instruction cannot be empty"); + } + const nowIso = now.toISOString(); + const job: AgentCronJob = { + id: randomUUID(), + status: "active", + source: "rlm_heartbeat", + activeSessionId: input.activeSessionId, + sessionId: input.sessionId, + sessionFile: input.sessionFile, + cwd: input.cwd, + label: normalizeOptionalLabel(input.label), + prompt, + schedule: parsed.schedule, + createdAt: nowIso, + updatedAt: nowIso, + nextRunAt: parsed.nextRunAt.toISOString(), + runCount: 0, + }; + this.writeJobs([...this.readJobs(), job]); + return job; + } + + updateRlmHeartbeat( + activeSessionId: string, + id: string, + update: { + label?: string; + prompt?: string; + scheduleText?: string; + status?: AgentRlmHeartbeatStatusUpdate; + now?: Date; + }, + ): AgentCronJob | undefined { + const now = update.now ?? new Date(); + let updated: AgentCronJob | undefined; + let matchedRlmHeartbeat = false; + const jobs = this.readJobs().map((job) => { + if (job.id !== id || job.activeSessionId !== activeSessionId || job.source !== "rlm_heartbeat") { + return job; + } + matchedRlmHeartbeat = true; + if (job.status === "cancelled" || job.status === "completed") { + updated = job; + return job; + } + let nextJob: AgentCronJob = { ...job }; + if (update.label !== undefined) { + nextJob = { ...nextJob, label: normalizeOptionalLabel(update.label) }; + } + if (update.prompt !== undefined) { + const prompt = update.prompt.trim(); + if (!prompt) { + throw new Error("RLM heartbeat instruction cannot be empty"); + } + nextJob = { ...nextJob, prompt }; + } + if (update.scheduleText !== undefined) { + const parsed = parseAgentCronSchedule(update.scheduleText, now); + if (parsed.schedule.kind === "once") { + throw new Error("RLM heartbeat schedule must be recurring"); + } + nextJob = + nextJob.status === "paused" + ? withoutNextRunAt({ ...nextJob, schedule: parsed.schedule }) + : { ...nextJob, schedule: parsed.schedule, nextRunAt: parsed.nextRunAt.toISOString() }; + } + if (update.status === "pause") { + nextJob = withoutNextRunAt({ ...nextJob, status: "paused" }); + } else if (update.status === "resume") { + const nextRunAt = nextRunAtForSchedule(nextJob.schedule, now); + if (!nextRunAt) { + throw new Error("RLM heartbeat schedule must be recurring"); + } + nextJob = { ...nextJob, status: "active", nextRunAt: nextRunAt.toISOString() }; + } + updated = { ...nextJob, updatedAt: now.toISOString() }; + return updated; + }); + if (matchedRlmHeartbeat && updated) { + this.writeJobs(jobs); + } + return updated; + } + + deleteRlmHeartbeat(activeSessionId: string, id: string, now = new Date()): AgentCronJob | undefined { + let deleted: AgentCronJob | undefined; + const jobs = this.readJobs().map((job) => { + if (job.id !== id || job.activeSessionId !== activeSessionId || job.source !== "rlm_heartbeat") { + return job; + } + deleted = withoutNextRunAt({ ...job, status: "cancelled", updatedAt: now.toISOString() }); + return deleted; + }); + if (deleted) { + this.writeJobs(jobs); + } + return deleted; + } + pauseHeartbeat(activeSessionId: string, now = new Date()): AgentCronJob | undefined { let paused: AgentCronJob | undefined; const current = this.getHeartbeat(activeSessionId); @@ -547,7 +687,8 @@ export function formatAgentCronJob(job: AgentCronJob): string { const last = job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "-"; const preview = job.prompt.replace(/\s+/g, " ").slice(0, 80); const error = job.lastError ? ` error=${job.lastError}` : ""; - return `${job.id} ${job.status} next=${next} last=${last} runs=${job.runCount} schedule="${job.schedule.expression}" prompt="${preview}"${error}`; + const label = job.label ? ` label="${job.label}"` : ""; + return `${job.id} ${job.status}${label} next=${next} last=${last} runs=${job.runCount} schedule="${job.schedule.expression}" prompt="${preview}"${error}`; } export function createAgentHeartbeatToolDefinitions(controller: AgentCronToolController): ToolDefinition[] { @@ -773,6 +914,16 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function normalizeOptionalLabel(label: string | undefined): string | undefined { + const trimmed = label?.trim(); + return trimmed ? trimmed : undefined; +} + +function withoutNextRunAt(job: AgentCronJob): AgentCronJob { + const { nextRunAt: _nextRunAt, ...rest } = job; + return rest; +} + function isAgentCronJob(value: unknown): value is AgentCronJob { if (!value || typeof value !== "object") { return false; @@ -784,11 +935,15 @@ function isAgentCronJob(value: unknown): value is AgentCronJob { candidate.status === "paused" || candidate.status === "completed" || candidate.status === "cancelled") && - (candidate.source === undefined || candidate.source === "cron" || candidate.source === "heartbeat") && + (candidate.source === undefined || + candidate.source === "cron" || + candidate.source === "heartbeat" || + candidate.source === "rlm_heartbeat") && typeof candidate.activeSessionId === "string" && typeof candidate.sessionId === "string" && typeof candidate.sessionFile === "string" && typeof candidate.cwd === "string" && + (candidate.label === undefined || typeof candidate.label === "string") && typeof candidate.prompt === "string" && typeof candidate.schedule === "object" && candidate.schedule !== null && diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index c6d9f3d0e7..2695f8e06a 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -349,6 +349,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} initialActiveToolNames, allowedToolNames, includeGoals, + rlmHeartbeatController: options.rlmHeartbeatController, extensionRunnerRef, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 57db10aec5..94f1462ddd 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -376,6 +376,32 @@ class AgentDaemon { }, }), ], + rlmHeartbeatController: { + listRlmHeartbeats: (listOptions) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); + }, + createRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.createRlmHeartbeatForState(stateRef, input); + }, + updateRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.updateRlmHeartbeatForState(stateRef, input); + }, + deleteRlmHeartbeat: (id) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.deleteRlmHeartbeatForState(stateRef, id); + }, + }, }, }); const state = await this.addRuntime(runtime, command.name); @@ -441,6 +467,52 @@ class AgentDaemon { return job; } + private createRlmHeartbeatForState( + state: ActiveSessionState, + input: { instruction: string; interval?: string; label?: string }, + ): AgentCronJob { + const session = state.runtime.session; + const sessionFile = session.sessionFile; + if (!sessionFile) { + throw new Error("RLM heartbeats require a persisted session file"); + } + const job = this.cronStore.createRlmHeartbeat({ + activeSessionId: state.activeSessionId, + sessionId: session.sessionId, + sessionFile, + cwd: state.runtime.cwd, + label: input.label, + scheduleText: normalizeHeartbeatSchedule(input.interval ?? DEFAULT_HEARTBEAT_SCHEDULE), + prompt: input.instruction, + }); + this.cronScheduler.wake(); + return job; + } + + private updateRlmHeartbeatForState( + state: ActiveSessionState, + input: { id: string; instruction?: string; interval?: string; label?: string; status?: "pause" | "resume" }, + ): AgentCronJob | undefined { + const job = this.cronStore.updateRlmHeartbeat(state.activeSessionId, input.id, { + label: input.label, + prompt: input.instruction, + scheduleText: input.interval ? normalizeHeartbeatSchedule(input.interval) : undefined, + status: input.status, + }); + if (job) { + this.cronScheduler.wake(); + } + return job; + } + + private deleteRlmHeartbeatForState(state: ActiveSessionState, id: string): AgentCronJob | undefined { + const job = this.cronStore.deleteRlmHeartbeat(state.activeSessionId, id); + if (job) { + this.cronScheduler.wake(); + } + return job; + } + private async getOrCreateCronJobSession(job: AgentCronJob): Promise { const current = this.sessions.get(job.activeSessionId) ?? this.findSessionBySessionFile(job.sessionFile); if (current) { @@ -524,30 +596,34 @@ class AgentDaemon { scopedModels: options.scopedModels, initialActiveToolNames: options.activeToolNames, allowedToolNames: options.allowedToolNames, - customTools: [ - ...(options.customTools ?? []), - ...createAgentHeartbeatToolDefinitions({ - getHeartbeat: () => { - if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); - } - return this.cronStore.getHeartbeat(stateRef.activeSessionId); - }, - createHeartbeat: (instruction, interval) => { - if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); - } - return this.createHeartbeatForState(stateRef, interval ?? DEFAULT_HEARTBEAT_SCHEDULE, instruction); - }, - updateHeartbeat: (action) => { - if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); - } - return this.updateHeartbeatForState(stateRef, action); - }, - }), - ], + customTools: options.customTools, includeGoals: options.includeGoals, + rlmHeartbeatController: { + listRlmHeartbeats: (listOptions) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); + }, + createRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.createRlmHeartbeatForState(stateRef, input); + }, + updateRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.updateRlmHeartbeatForState(stateRef, input); + }, + deleteRlmHeartbeat: (id) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.deleteRlmHeartbeatForState(stateRef, id); + }, + }, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, rlmSessionDir: options.sessionDir, diff --git a/packages/coding-agent/test/builtin-skills.test.ts b/packages/coding-agent/test/builtin-skills.test.ts index a8690222e0..e3df4c2aaf 100644 --- a/packages/coding-agent/test/builtin-skills.test.ts +++ b/packages/coding-agent/test/builtin-skills.test.ts @@ -172,6 +172,15 @@ describe("builtin skills", () => { expect(goal?.kind === "python" && goal.python.importName).toBe("goal"); }); + it("loads the bundled RLM heartbeat skill as a python skill", () => { + const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); + + const rlmHeartbeat = skills.find((s) => s.name === "rlm-heartbeat"); + expect(rlmHeartbeat).toBeDefined(); + expect(rlmHeartbeat?.kind).toBe("python"); + expect(rlmHeartbeat?.kind === "python" && rlmHeartbeat.python.importName).toBe("rlm_heartbeat"); + }); + it("ships the edit skill as a python skill importable as `edit`", () => { const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 9b7f7db749..0c760db578 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -191,6 +191,118 @@ describe("AgentCronJobStore", () => { }), ).toThrow("Heartbeat schedule must be recurring"); }); + + it("keeps multiple RLM heartbeats separate from the single user heartbeat", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const userHeartbeat = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on the user", + now: start, + }); + const firstRlmHeartbeat = store.createRlmHeartbeat({ + activeSessionId: "rlm-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + label: "tests", + scheduleText: "every 30s", + prompt: "rerun focused tests", + now: start, + }); + const secondRlmHeartbeat = store.createRlmHeartbeat({ + activeSessionId: "rlm-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + label: "review", + scheduleText: "every 10m", + prompt: "review the latest output", + now: start, + }); + + expect(store.getHeartbeat("active-1")).toMatchObject({ id: userHeartbeat.id, source: "heartbeat" }); + expect(store.listRlmHeartbeats("active-1")).toEqual([]); + expect(store.listRlmHeartbeats("rlm-1")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: firstRlmHeartbeat.id, source: "rlm_heartbeat", label: "tests" }), + expect.objectContaining({ id: secondRlmHeartbeat.id, source: "rlm_heartbeat", label: "review" }), + ]), + ); + expect(store.getHeartbeat("active-1")).toMatchObject({ id: userHeartbeat.id, status: "active" }); + }); + + it("updates and deletes only RLM heartbeats in the matching RLM session", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const userHeartbeat = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on the user", + now: start, + }); + const rlmHeartbeat = store.createRlmHeartbeat({ + activeSessionId: "rlm-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + label: "tests", + scheduleText: "every 30s", + prompt: "rerun focused tests", + now: start, + }); + + expect( + store.updateRlmHeartbeat("active-1", userHeartbeat.id, { + prompt: "try to mutate user heartbeat", + now: new Date("2026-01-01T12:35:00.000Z"), + }), + ).toBeUndefined(); + expect( + store.updateRlmHeartbeat("rlm-2", rlmHeartbeat.id, { + prompt: "try wrong RLM session", + now: new Date("2026-01-01T12:35:00.000Z"), + }), + ).toBeUndefined(); + + const updated = store.updateRlmHeartbeat("rlm-1", rlmHeartbeat.id, { + label: "focused-tests", + prompt: "rerun focused tests and inspect failures", + scheduleText: "every 10m", + status: "pause", + now: new Date("2026-01-01T12:35:00.000Z"), + }); + + expect(updated).toMatchObject({ + id: rlmHeartbeat.id, + label: "focused-tests", + prompt: "rerun focused tests and inspect failures", + status: "paused", + schedule: { expression: "every 10m" }, + }); + expect(updated).not.toHaveProperty("nextRunAt"); + expect(store.getHeartbeat("active-1")).toMatchObject({ + id: userHeartbeat.id, + prompt: "check on the user", + status: "active", + }); + + expect(store.deleteRlmHeartbeat("active-1", userHeartbeat.id)).toBeUndefined(); + expect(store.deleteRlmHeartbeat("rlm-1", rlmHeartbeat.id, new Date("2026-01-01T12:36:00.000Z"))).toMatchObject({ + id: rlmHeartbeat.id, + status: "cancelled", + }); + expect(store.listRlmHeartbeats("rlm-1")).toEqual([]); + expect(store.listRlmHeartbeats("rlm-1", { includeInactive: true })[0]).toMatchObject({ + id: rlmHeartbeat.id, + status: "cancelled", + }); + }); }); describe("AgentCronScheduler", () => { diff --git a/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts b/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts new file mode 100644 index 0000000000..be05bef51a --- /dev/null +++ b/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts @@ -0,0 +1,148 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getBundledSkillsDir } from "../src/config.js"; +import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; +import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; + +function bundledRlmHeartbeatSkill(): PythonSkillRuntimeInfo { + const packagePath = join(getBundledSkillsDir(), "rlm-heartbeat"); + return { + name: "rlm-heartbeat", + importName: "rlm_heartbeat", + packagePath, + pyprojectPath: join(packagePath, "pyproject.toml"), + }; +} + +describe("RLM heartbeat skill over the kernel host bridge", () => { + let tempDir: string; + let provisioner: IpythonKernelProvisioner | undefined; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-rlm-heartbeat-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await provisioner?.dispose(); + provisioner = undefined; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("round-trips create, list, update, and delete through a live kernel", async () => { + const requests: Array<{ type: string; payload: Record }> = []; + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledRlmHeartbeatSkill()], + hostHandlers: { + "rlm_heartbeat.create": async (payload) => { + requests.push({ type: "rlm_heartbeat.create", payload }); + return { + heartbeat: { + id: "job-1", + status: "active", + label: payload.label ?? null, + instruction: payload.instruction, + schedule: { kind: "interval", expression: payload.interval ?? "every 5m" }, + next_run_at: "2026-01-01T12:39:00.000Z", + run_count: 0, + }, + }; + }, + "rlm_heartbeat.list": async (payload) => { + requests.push({ type: "rlm_heartbeat.list", payload }); + return { + heartbeats: [ + { + id: "job-1", + status: "active", + label: "tests", + instruction: "check tests", + }, + ], + }; + }, + "rlm_heartbeat.update": async (payload) => { + requests.push({ type: "rlm_heartbeat.update", payload }); + return { + heartbeat: { + id: payload.id, + status: "paused", + label: "tests", + instruction: "check tests", + }, + }; + }, + "rlm_heartbeat.delete": async (payload) => { + requests.push({ type: "rlm_heartbeat.delete", payload }); + return { + heartbeat: { + id: payload.id, + status: "cancelled", + label: "tests", + instruction: "check tests", + }, + }; + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +import json +created = await rlm_heartbeat.create("check tests", interval="5m", label="tests") +listed = await rlm_heartbeat.list(include_inactive=True) +updated = await rlm_heartbeat.update(created["heartbeat"]["id"], status="pause") +deleted = await rlm_heartbeat.delete(created["heartbeat"]["id"]) +print(json.dumps({ + "created": created["heartbeat"], + "listed": listed["heartbeats"], + "updated": updated["heartbeat"], + "deleted": deleted["heartbeat"], +}, sort_keys=True)) +`); + + expect(result.status).toBe("ok"); + expect(JSON.parse(result.stdout.trim())).toMatchObject({ + created: { id: "job-1", status: "active", label: "tests", instruction: "check tests" }, + listed: [{ id: "job-1", status: "active", label: "tests", instruction: "check tests" }], + updated: { id: "job-1", status: "paused" }, + deleted: { id: "job-1", status: "cancelled" }, + }); + expect(requests.map((request) => request.type)).toEqual([ + "rlm_heartbeat.create", + "rlm_heartbeat.list", + "rlm_heartbeat.update", + "rlm_heartbeat.delete", + ]); + expect(requests[0].payload).toMatchObject({ + type: "rlm_heartbeat.create", + instruction: "check tests", + interval: "5m", + label: "tests", + }); + expect(requests[1].payload).toMatchObject({ type: "rlm_heartbeat.list", include_inactive: true }); + expect(requests[2].payload).toMatchObject({ type: "rlm_heartbeat.update", id: "job-1", status: "pause" }); + expect(requests[3].payload).toMatchObject({ type: "rlm_heartbeat.delete", id: "job-1" }); + }); + + it("surfaces missing host handlers as Python exceptions", async () => { + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledRlmHeartbeatSkill()], + hostHandlers: {}, + }); + + const manager = await provisioner.ensure(); + const unavailable = await manager.execute(` +try: + await rlm_heartbeat.list() +except RuntimeError as error: + print(f"RuntimeError: {error}") +`); + expect(unavailable.status).toBe("ok"); + expect(unavailable.stdout.trim()).toBe( + 'RuntimeError: host request type "rlm_heartbeat.list" is not available in this session', + ); + }); +}); From a3ecfcc1f1dd0dfb8674f33bdb32e80b514b425d Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 15 Jun 2026 16:26:48 -0700 Subject: [PATCH 03/17] Fix RLM heartbeat controller propagation --- packages/coding-agent/src/main.ts | 39 +++++++++++------- packages/coding-agent/test/cron-jobs.test.ts | 33 +++++++++++++++ .../test/main-interactive-routing.test.ts | 40 +++++++++++++++++++ 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 526cf7a571..3997caf118 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -588,6 +588,29 @@ interface PreparedRuntimeServices { diagnostics: AgentSessionRuntimeDiagnostic[]; } +export function resolveRuntimeSessionOptions( + sessionOptions: CreateAgentSessionOptions, + runtimeSessionOptions?: CreateAgentSessionOptions, +): CreateAgentSessionOptions { + return { + model: runtimeSessionOptions?.model ?? sessionOptions.model, + thinkingLevel: runtimeSessionOptions?.thinkingLevel ?? sessionOptions.thinkingLevel, + scopedModels: runtimeSessionOptions?.scopedModels ?? sessionOptions.scopedModels, + tools: runtimeSessionOptions?.tools ?? sessionOptions.tools, + noTools: runtimeSessionOptions?.noTools ?? sessionOptions.noTools, + customTools: runtimeSessionOptions?.customTools ?? sessionOptions.customTools, + initialActiveToolNames: runtimeSessionOptions?.initialActiveToolNames, + allowedToolNames: runtimeSessionOptions?.allowedToolNames, + includeGoals: runtimeSessionOptions?.includeGoals, + rlmHeartbeatController: runtimeSessionOptions?.rlmHeartbeatController, + rlmDepth: runtimeSessionOptions?.rlmDepth, + rlmMaxDepth: runtimeSessionOptions?.rlmMaxDepth, + rlmSessionDir: runtimeSessionOptions?.rlmSessionDir, + rlmParentNodeId: runtimeSessionOptions?.rlmParentNodeId, + subagentRuntimeHost: runtimeSessionOptions?.subagentRuntimeHost, + }; +} + async function prepareRuntimeServices(options: { config: AgentSessionRuntimeConfig; cwd: string; @@ -1078,25 +1101,13 @@ export async function main(args: string[], options?: MainOptions) { sessionOptionsOverride: runtimeSessionOptions, }); const { services, sessionOptions, diagnostics } = prepared; + const resolvedSessionOptions = resolveRuntimeSessionOptions(sessionOptions, runtimeSessionOptions); const created = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, - model: runtimeSessionOptions?.model ?? sessionOptions.model, - thinkingLevel: runtimeSessionOptions?.thinkingLevel ?? sessionOptions.thinkingLevel, - scopedModels: runtimeSessionOptions?.scopedModels ?? sessionOptions.scopedModels, - tools: runtimeSessionOptions?.tools ?? sessionOptions.tools, - noTools: runtimeSessionOptions?.noTools ?? sessionOptions.noTools, - customTools: runtimeSessionOptions?.customTools ?? sessionOptions.customTools, - initialActiveToolNames: runtimeSessionOptions?.initialActiveToolNames, - allowedToolNames: runtimeSessionOptions?.allowedToolNames, - includeGoals: runtimeSessionOptions?.includeGoals, - rlmDepth: runtimeSessionOptions?.rlmDepth, - rlmMaxDepth: runtimeSessionOptions?.rlmMaxDepth, - rlmSessionDir: runtimeSessionOptions?.rlmSessionDir, - rlmParentNodeId: runtimeSessionOptions?.rlmParentNodeId, - subagentRuntimeHost: runtimeSessionOptions?.subagentRuntimeHost, + ...resolvedSessionOptions, // Main agents boot their kernel in the background at session creation; // subagent sessions (rlmDepth > 0) keep the lazy first-call start. prewarmIpythonKernel: true, diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 0c760db578..18a0fef97f 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -396,6 +396,39 @@ describe("AgentCronScheduler", () => { nextRunAt: "2026-01-01T12:35:00.000Z", }); }); + + it("does not run an RLM heartbeat that was deleted before it became due", async () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const job = store.createRlmHeartbeat({ + activeSessionId: "rlm-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + label: "delete-before-fire", + scheduleText: "every 30s", + prompt: "this should never run", + now: start, + }); + store.deleteRlmHeartbeat("rlm-1", job.id, new Date("2026-01-01T12:34:10.000Z")); + const prompts: string[] = []; + const scheduler = new AgentCronScheduler(store, { + now: () => new Date("2026-01-01T12:34:31.000Z"), + runJob: async (dueJob) => { + prompts.push(dueJob.prompt); + }, + }); + + const handled = await scheduler.runDue(new Date("2026-01-01T12:34:31.000Z")); + + expect(handled).toBe(0); + expect(prompts).toEqual([]); + expect(store.listRlmHeartbeats("rlm-1")).toEqual([]); + expect(store.listRlmHeartbeats("rlm-1", { includeInactive: true })[0]).toMatchObject({ + id: job.id, + status: "cancelled", + runCount: 0, + }); + }); }); describe("createAgentHeartbeatToolDefinitions", () => { diff --git a/packages/coding-agent/test/main-interactive-routing.test.ts b/packages/coding-agent/test/main-interactive-routing.test.ts index eabf9b5f72..bab80044e2 100644 --- a/packages/coding-agent/test/main-interactive-routing.test.ts +++ b/packages/coding-agent/test/main-interactive-routing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import type { CreateAgentSessionOptions } from "../src/core/sdk.js"; import { type AppMode, type DaemonInteractiveSessionManagerDecision, @@ -6,6 +7,7 @@ import { findActiveDaemonSessionSummaryForSessionFile, type InteractiveDaemonStartupDecision, parseDaemonRichTuiAttachShortcut, + resolveRuntimeSessionOptions, shouldEnsureDaemonBeforeActiveSessionLookup, shouldOpenAgentsViewForDaemonInteractive, shouldUseDaemonInteractive, @@ -189,6 +191,44 @@ describe("daemon rich TUI attach shortcut parsing", () => { }); }); +describe("runtime session option resolution", () => { + test("preserves daemon-provided RLM heartbeat controller when creating sessions", () => { + const preparedModel = { id: "prepared-model" } as unknown as CreateAgentSessionOptions["model"]; + const runtimeModel = { id: "runtime-model" } as unknown as CreateAgentSessionOptions["model"]; + const rlmHeartbeatController: NonNullable = { + listRlmHeartbeats: () => [], + createRlmHeartbeat: () => { + throw new Error("not used"); + }, + updateRlmHeartbeat: () => undefined, + deleteRlmHeartbeat: () => undefined, + }; + + const resolved = resolveRuntimeSessionOptions( + { + model: preparedModel, + tools: ["ipython"], + customTools: [], + }, + { + model: runtimeModel, + rlmHeartbeatController, + rlmDepth: 1, + rlmSessionDir: "/tmp/rlm-session", + }, + ); + + expect(resolved).toMatchObject({ + model: runtimeModel, + tools: ["ipython"], + customTools: [], + rlmHeartbeatController, + rlmDepth: 1, + rlmSessionDir: "/tmp/rlm-session", + }); + }); +}); + function makeSessionSummary(overrides: Partial): SessionSummary { return { id: "session-1", From 74a19ec3f385af2f66f85db6062534d40a85b633 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 15 Jun 2026 16:34:09 -0700 Subject: [PATCH 04/17] Make user heartbeats user-controlled --- packages/coding-agent/src/core/cron-jobs.ts | 69 +------------------ .../src/modes/daemon/daemon-mode.ts | 12 ---- packages/coding-agent/test/cron-jobs.test.ts | 62 ++--------------- 3 files changed, 6 insertions(+), 137 deletions(-) diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 0702393660..20e37f2029 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; -import { type Static, Type } from "typebox"; +import { Type } from "typebox"; import type { ToolDefinition } from "./extensions/types.js"; export type AgentCronJobStatus = "active" | "paused" | "completed" | "cancelled"; @@ -62,35 +62,6 @@ const ONE_SECOND_MS = 1000; const ONE_MINUTE_MS = 60_000; export const DEFAULT_HEARTBEAT_SCHEDULE = "every 5m"; -const createHeartbeatSchema = Type.Object( - { - instruction: Type.String({ - description: - "Required instruction to inject into this same session on each heartbeat. Include enough context for the future turn to act safely.", - }), - interval: Type.Optional( - Type.String({ - description: - "Optional heartbeat cadence. Defaults to 'every 5m'. Examples: '30s', 'every 10m', '@hourly', or '*/30 * * * *'.", - }), - ), - }, - { additionalProperties: false }, -); - -const updateHeartbeatSchema = Type.Object( - { - action: Type.Union([Type.Literal("pause"), Type.Literal("resume"), Type.Literal("clear")], { - description: - "Heartbeat lifecycle action. Use 'pause' to stop firing temporarily, 'resume' to continue, or 'clear' to remove it.", - }), - }, - { additionalProperties: false }, -); - -type CreateHeartbeatArgs = Static; -type UpdateHeartbeatArgs = Static; - export type ParsedHeartbeatCommand = | { type: "status" } | { type: "pause" } @@ -100,8 +71,6 @@ export type ParsedHeartbeatCommand = export interface AgentCronToolController { getHeartbeat(): AgentCronJob | undefined; - createHeartbeat(instruction: string, interval?: string): AgentCronJob; - updateHeartbeat(action: AgentHeartbeatUpdateAction): AgentCronJob | undefined; } export interface AgentRlmHeartbeatController { @@ -709,42 +678,6 @@ export function createAgentHeartbeatToolDefinitions(controller: AgentCronToolCon }; }, }, - { - name: "create_heartbeat", - label: "Create Heartbeat", - description: - "Create or replace the single persistent heartbeat for this same daemon-backed session. Use this only when the user explicitly asks for a recurring check-in, reminder, heartbeat, or continuation.", - promptGuidelines: [ - "Use create_heartbeat when the user explicitly asks this session to keep checking in or continue itself on a cadence.", - "Do not create heartbeats on your own initiative. If the requested instruction is ambiguous, ask a concise follow-up.", - "The interval defaults to every 5 minutes when the user does not specify one.", - ], - parameters: createHeartbeatSchema, - execute: async (_toolCallId: string, params: CreateHeartbeatArgs) => { - const job = controller.createHeartbeat(params.instruction, params.interval); - return { - content: [{ type: "text", text: JSON.stringify({ heartbeat: job }, null, 2) }], - details: job, - }; - }, - }, - { - name: "update_heartbeat", - label: "Update Heartbeat", - description: - "Pause, resume, or clear the persistent heartbeat for this daemon-backed session. Use this only when the user explicitly asks to change heartbeat lifecycle state.", - promptGuidelines: [ - "Use update_heartbeat only when the user explicitly asks to pause, resume, stop, or clear the heartbeat.", - ], - parameters: updateHeartbeatSchema, - execute: async (_toolCallId: string, params: UpdateHeartbeatArgs) => { - const job = controller.updateHeartbeat(params.action); - return { - content: [{ type: "text", text: JSON.stringify({ heartbeat: job ?? null }, null, 2) }], - details: job ?? null, - }; - }, - }, ]; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 94f1462ddd..7ffdfda02d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -362,18 +362,6 @@ class AgentDaemon { } return this.cronStore.getHeartbeat(stateRef.activeSessionId); }, - createHeartbeat: (instruction, interval) => { - if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); - } - return this.createHeartbeatForState(stateRef, interval ?? DEFAULT_HEARTBEAT_SCHEDULE, instruction); - }, - updateHeartbeat: (action) => { - if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); - } - return this.updateHeartbeatForState(stateRef, action); - }, }), ], rlmHeartbeatController: { diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 18a0fef97f..3eedcb32ad 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -432,47 +432,15 @@ describe("AgentCronScheduler", () => { }); describe("createAgentHeartbeatToolDefinitions", () => { - it("lets the model create a heartbeat when explicitly requested", async () => { + it("exposes only read-only user heartbeat inspection to the model", () => { const tools = createAgentHeartbeatToolDefinitions({ getHeartbeat: () => undefined, - createHeartbeat: (instruction, interval) => - ({ - id: "job-1", - status: "active", - source: "heartbeat", - activeSessionId: "active-1", - sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", - cwd: "/tmp/project", - prompt: instruction, - schedule: { kind: "interval", expression: interval ?? "every 5m", intervalMs: 30_000 }, - createdAt: start.toISOString(), - updatedAt: start.toISOString(), - nextRunAt: "2026-01-01T12:34:30.000Z", - runCount: 0, - }) as const, - updateHeartbeat: () => undefined, }); - const tool = tools.find((candidate) => candidate.name === "create_heartbeat"); - - expect(tool).toBeDefined(); - - const result = await tool!.execute( - "tool-1", - { interval: "every 30s", instruction: "check on me" }, - undefined, - undefined, - {} as never, - ); - expect(result.details).toMatchObject({ - id: "job-1", - schedule: { expression: "every 30s" }, - prompt: "check on me", - }); + expect(tools.map((tool) => tool.name)).toEqual(["get_heartbeat"]); }); - it("lets the model inspect and update heartbeat state", async () => { + it("lets the model inspect heartbeat state without mutating it", async () => { const tools = createAgentHeartbeatToolDefinitions({ getHeartbeat: () => ({ @@ -490,35 +458,15 @@ describe("createAgentHeartbeatToolDefinitions", () => { nextRunAt: "2026-01-01T12:34:30.000Z", runCount: 0, }) as const, - createHeartbeat: () => { - throw new Error("not used"); - }, - updateHeartbeat: (action) => - ({ - id: "job-1", - status: action === "pause" ? "paused" : "cancelled", - source: "heartbeat", - activeSessionId: "active-1", - sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", - cwd: "/tmp/project", - prompt: "check on me", - schedule: { kind: "interval", expression: "every 30s", intervalMs: 30_000 }, - createdAt: start.toISOString(), - updatedAt: start.toISOString(), - runCount: 0, - }) as const, }); const getResult = await tools .find((candidate) => candidate.name === "get_heartbeat")! .execute("tool-1", {}, undefined, undefined, {} as never); - const updateResult = await tools - .find((candidate) => candidate.name === "update_heartbeat")! - .execute("tool-2", { action: "pause" }, undefined, undefined, {} as never); expect(getResult.details).toMatchObject({ id: "job-1", status: "active" }); - expect(updateResult.details).toMatchObject({ id: "job-1", status: "paused" }); + expect(tools.find((candidate) => candidate.name === "create_heartbeat")).toBeUndefined(); + expect(tools.find((candidate) => candidate.name === "update_heartbeat")).toBeUndefined(); }); }); From 05b4edda1d809edd46081a00f76b5c17fa2cb3b9 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 15 Jun 2026 20:31:49 -0700 Subject: [PATCH 05/17] Validate heartbeat get session --- .../src/modes/daemon/daemon-mode.ts | 5 ++-- .../coding-agent/test/daemon-mode.test.ts | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 418093d755..639cddab35 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -166,7 +166,7 @@ export async function runDaemonMode(options: DaemonModeOptions): Promise return new Promise(() => {}); } -class AgentDaemon { +export class AgentDaemon { private server?: Server; private shuttingDown = false; private ownsSocketPath = false; @@ -1020,7 +1020,8 @@ class AgentDaemon { } case "heartbeat_get": { - const heartbeat = this.cronStore.getHeartbeat(command.activeSessionId); + const state = this.getSessionState(command.activeSessionId); + const heartbeat = this.cronStore.getHeartbeat(state.activeSessionId); return success(command.id, "heartbeat_get", { heartbeat: heartbeat ?? null }); } diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 77cf925661..335ccd8b31 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -2,11 +2,13 @@ import type { Socket } from "node:net"; import { describe, expect, it, vi } from "vitest"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; import { + AgentDaemon, cancelPendingExtensionUiRequests, detachClientFromActiveSession, getChildActiveSessionStates, shouldSendDaemonOutboundToClient, } from "../src/modes/daemon/daemon-mode.js"; +import type { DaemonCommand } from "../src/modes/daemon/daemon-protocol.js"; describe("daemon mode helpers", () => { it("finds only direct child active sessions", () => { @@ -83,6 +85,31 @@ describe("daemon mode helpers", () => { }), ).toBe(true); }); + + it("validates active sessions before reading a heartbeat", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { + agentDir: "/tmp/prime-agent-test-agent", + cwd: "/tmp", + }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const handleCommand = ( + daemon as unknown as { + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + } + ).handleCommand.bind(daemon); + + await expect( + handleCommand(makeClient("client-1", "missing"), { + id: "command-1", + type: "heartbeat_get", + activeSessionId: "missing", + }), + ).rejects.toThrow("Unknown active session: missing"); + }); }); function makeState(activeSessionId: string, parentActiveSessionId?: string): ActiveSessionState { From f5da55ddbb8773e0a95b6511e3885f57facc3893 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 13:43:51 -0700 Subject: [PATCH 06/17] fix(coding-agent): rebind heartbeat jobs on daemon restart --- packages/coding-agent/src/core/cron-jobs.ts | 42 +++++++++- .../src/modes/daemon/daemon-mode.ts | 22 +++++ packages/coding-agent/test/cron-jobs.test.ts | 80 +++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 20e37f2029..dcb3a283d5 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { Type } from "typebox"; import type { ToolDefinition } from "./extensions/types.js"; @@ -121,6 +121,45 @@ export class AgentCronJobStore { return job; } + /** + * Active session ids are daemon-local. When a persisted session is restored, + * bind jobs stored for its stable session file to the new live session id so + * heartbeat and cron control APIs continue to target existing schedules. + */ + rebindSessionJobs(input: { + activeSessionId: string; + sessionId: string; + sessionFile: string; + cwd: string; + }): AgentCronJob[] { + const targetSessionFile = resolve(input.sessionFile); + const reboundJobs: AgentCronJob[] = []; + const jobs = this.readJobs().map((job) => { + if (resolve(job.sessionFile) !== targetSessionFile) { + return job; + } + if ( + job.activeSessionId === input.activeSessionId && + job.sessionId === input.sessionId && + job.cwd === input.cwd + ) { + return job; + } + const rebound = { + ...job, + activeSessionId: input.activeSessionId, + sessionId: input.sessionId, + cwd: input.cwd, + }; + reboundJobs.push(rebound); + return rebound; + }); + if (reboundJobs.length > 0) { + this.writeJobs(jobs); + } + return reboundJobs; + } + getHeartbeat(activeSessionId: string): AgentCronJob | undefined { return this.readJobs() .filter((job) => { @@ -239,7 +278,6 @@ export class AgentCronJobStore { } matchedRlmHeartbeat = true; if (job.status === "cancelled" || job.status === "completed") { - updated = job; return job; } let nextJob: AgentCronJob = { ...job }; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 639cddab35..eb9018d83d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -298,6 +298,7 @@ export class AgentDaemon { throw error; } this.sessions.set(state.activeSessionId, state); + this.rebindCronJobsToState(state); if (name) { state.runtime.session.setSessionName(name); } @@ -340,6 +341,7 @@ export class AgentDaemon { if (command.name) { existing.runtime.session.setSessionName(command.name); } + this.rebindCronJobsToState(existing); return existing; } if ((sessionPath || command.continueRecent) && sessionManager.getSessionState()?.status === "hidden") { @@ -501,9 +503,26 @@ export class AgentDaemon { return job; } + private rebindCronJobsToState(state: ActiveSessionState): void { + const sessionFile = state.runtime.session.sessionFile; + if (!sessionFile) { + return; + } + const reboundJobs = this.cronStore.rebindSessionJobs({ + activeSessionId: state.activeSessionId, + sessionId: state.runtime.session.sessionId, + sessionFile, + cwd: state.runtime.cwd, + }); + if (reboundJobs.some((job) => job.status === "active")) { + this.cronScheduler.wake(); + } + } + private async getOrCreateCronJobSession(job: AgentCronJob): Promise { const current = this.sessions.get(job.activeSessionId) ?? this.findSessionBySessionFile(job.sessionFile); if (current) { + this.rebindCronJobsToState(current); return current; } return this.createRuntime({ type: "create", sessionPath: job.sessionFile }); @@ -1143,6 +1162,7 @@ export class AgentDaemon { const state = this.getSessionState(command.activeSessionId); const options = command.parentSession ? { parentSession: command.parentSession } : undefined; const result = await state.runtime.newSession(options); + this.rebindCronJobsToState(state); return success(command.id, "new_session", result); } @@ -1151,6 +1171,7 @@ export class AgentDaemon { const result = await state.runtime.switchSession(command.sessionPath, { cwdOverride: command.cwdOverride, }); + this.rebindCronJobsToState(state); return success(command.id, "switch_session", result); } @@ -1159,6 +1180,7 @@ export class AgentDaemon { const result = await state.runtime.fork(command.entryId, { position: command.position, }); + this.rebindCronJobsToState(state); return success(command.id, "fork", result); } diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 3eedcb32ad..3994ceea65 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -192,6 +192,59 @@ describe("AgentCronJobStore", () => { ).toThrow("Heartbeat schedule must be recurring"); }); + it("rebinds persisted session jobs to a new daemon active session id", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const userHeartbeat = store.createHeartbeat({ + activeSessionId: "old-active", + sessionId: "old-session", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on the user", + now: start, + }); + const rlmHeartbeat = store.createRlmHeartbeat({ + activeSessionId: "old-active", + sessionId: "old-session", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + label: "review", + scheduleText: "every 10m", + prompt: "review the latest output", + now: start, + }); + store.createHeartbeat({ + activeSessionId: "other-active", + sessionId: "other-session", + sessionFile: "/tmp/other-session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on a different session", + now: start, + }); + + const rebound = store.rebindSessionJobs({ + activeSessionId: "new-active", + sessionId: "new-session", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project-restored", + }); + + expect(rebound.map((job) => job.id)).toEqual(expect.arrayContaining([userHeartbeat.id, rlmHeartbeat.id])); + expect(store.getHeartbeat("old-active")).toBeUndefined(); + expect(store.getHeartbeat("new-active")).toMatchObject({ + id: userHeartbeat.id, + sessionId: "new-session", + cwd: "/tmp/project-restored", + }); + expect(store.listRlmHeartbeats("new-active")[0]).toMatchObject({ + id: rlmHeartbeat.id, + sessionId: "new-session", + cwd: "/tmp/project-restored", + }); + expect(store.getHeartbeat("other-active")).toMatchObject({ prompt: "check on a different session" }); + }); + it("keeps multiple RLM heartbeats separate from the single user heartbeat", () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const userHeartbeat = store.createHeartbeat({ @@ -235,6 +288,33 @@ describe("AgentCronJobStore", () => { expect(store.getHeartbeat("active-1")).toMatchObject({ id: userHeartbeat.id, status: "active" }); }); + it("returns undefined when updating inactive RLM heartbeats", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const rlmHeartbeat = store.createRlmHeartbeat({ + activeSessionId: "rlm-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + label: "tests", + scheduleText: "every 30s", + prompt: "rerun focused tests", + now: start, + }); + store.deleteRlmHeartbeat("rlm-1", rlmHeartbeat.id, new Date("2026-01-01T12:35:00.000Z")); + + expect( + store.updateRlmHeartbeat("rlm-1", rlmHeartbeat.id, { + prompt: "try to update cancelled heartbeat", + now: new Date("2026-01-01T12:36:00.000Z"), + }), + ).toBeUndefined(); + expect(store.listRlmHeartbeats("rlm-1", { includeInactive: true })[0]).toMatchObject({ + id: rlmHeartbeat.id, + status: "cancelled", + prompt: "rerun focused tests", + }); + }); + it("updates and deletes only RLM heartbeats in the matching RLM session", () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const userHeartbeat = store.createHeartbeat({ From b713f6eafb1525eb8e79d19901021c91843964cd Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 13:54:47 -0700 Subject: [PATCH 07/17] fix(coding-agent): clean up stale heartbeat jobs --- packages/coding-agent/src/core/cron-jobs.ts | 39 +++++- .../src/modes/daemon/daemon-mode.ts | 24 +++- packages/coding-agent/test/cron-jobs.test.ts | 112 ++++++++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index dcb3a283d5..11e330d9ab 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -7,6 +7,7 @@ import type { ToolDefinition } from "./extensions/types.js"; export type AgentCronJobStatus = "active" | "paused" | "completed" | "cancelled"; export type AgentCronScheduleKind = "once" | "cron" | "interval"; export type AgentCronJobSource = "cron" | "heartbeat" | "rlm_heartbeat"; +export type AgentCronJobRuntimeKind = "top-level" | "subagent"; export type AgentHeartbeatUpdateAction = "pause" | "resume" | "clear"; export type AgentRlmHeartbeatStatusUpdate = "pause" | "resume"; @@ -20,6 +21,7 @@ export interface AgentCronJob { id: string; status: AgentCronJobStatus; source?: AgentCronJobSource; + runtimeKind?: AgentCronJobRuntimeKind; activeSessionId: string; sessionId: string; sessionFile: string; @@ -44,6 +46,7 @@ export interface CreateAgentCronJobInput { prompt: string; scheduleText: string; source?: AgentCronJobSource; + runtimeKind?: AgentCronJobRuntimeKind; now?: Date; } @@ -105,6 +108,7 @@ export class AgentCronJobStore { id: randomUUID(), status: "active", source: input.source ?? "cron", + runtimeKind: input.runtimeKind, activeSessionId: input.activeSessionId, sessionId: input.sessionId, sessionFile: input.sessionFile, @@ -123,8 +127,10 @@ export class AgentCronJobStore { /** * Active session ids are daemon-local. When a persisted session is restored, - * bind jobs stored for its stable session file to the new live session id so - * heartbeat and cron control APIs continue to target existing schedules. + * bind jobs stored for its stable session file to the new live session id. + * When a live session switches to another persisted file, move jobs stored for + * its stable active session id to the new file so future restores target the + * current session instead of the previous one. */ rebindSessionJobs(input: { activeSessionId: string; @@ -135,12 +141,13 @@ export class AgentCronJobStore { const targetSessionFile = resolve(input.sessionFile); const reboundJobs: AgentCronJob[] = []; const jobs = this.readJobs().map((job) => { - if (resolve(job.sessionFile) !== targetSessionFile) { + if (job.activeSessionId !== input.activeSessionId && resolve(job.sessionFile) !== targetSessionFile) { return job; } if ( job.activeSessionId === input.activeSessionId && job.sessionId === input.sessionId && + resolve(job.sessionFile) === targetSessionFile && job.cwd === input.cwd ) { return job; @@ -149,6 +156,7 @@ export class AgentCronJobStore { ...job, activeSessionId: input.activeSessionId, sessionId: input.sessionId, + sessionFile: input.sessionFile, cwd: input.cwd, }; reboundJobs.push(rebound); @@ -197,6 +205,7 @@ export class AgentCronJobStore { id: randomUUID(), status: "active", source: "heartbeat", + runtimeKind: input.runtimeKind, activeSessionId: input.activeSessionId, sessionId: input.sessionId, sessionFile: input.sessionFile, @@ -242,6 +251,7 @@ export class AgentCronJobStore { id: randomUUID(), status: "active", source: "rlm_heartbeat", + runtimeKind: input.runtimeKind, activeSessionId: input.activeSessionId, sessionId: input.sessionId, sessionFile: input.sessionFile, @@ -334,6 +344,26 @@ export class AgentCronJobStore { return deleted; } + cancelRlmHeartbeatsForSession(activeSessionId: string, now = new Date()): AgentCronJob[] { + const cancelled: AgentCronJob[] = []; + const jobs = this.readJobs().map((job) => { + if ( + job.activeSessionId !== activeSessionId || + job.source !== "rlm_heartbeat" || + (job.status !== "active" && job.status !== "paused") + ) { + return job; + } + const cancelledJob = withoutNextRunAt({ ...job, status: "cancelled", updatedAt: now.toISOString() }); + cancelled.push(cancelledJob); + return cancelledJob; + }); + if (cancelled.length > 0) { + this.writeJobs(jobs); + } + return cancelled; + } + pauseHeartbeat(activeSessionId: string, now = new Date()): AgentCronJob | undefined { let paused: AgentCronJob | undefined; const current = this.getHeartbeat(activeSessionId); @@ -910,6 +940,9 @@ function isAgentCronJob(value: unknown): value is AgentCronJob { candidate.source === "cron" || candidate.source === "heartbeat" || candidate.source === "rlm_heartbeat") && + (candidate.runtimeKind === undefined || + candidate.runtimeKind === "top-level" || + candidate.runtimeKind === "subagent") && typeof candidate.activeSessionId === "string" && typeof candidate.sessionId === "string" && typeof candidate.sessionFile === "string" && diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index eb9018d83d..53a470bdde 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -401,6 +401,9 @@ export class AgentDaemon { private async runCronJob(job: AgentCronJob): Promise { const state = await this.getOrCreateCronJobSession(job); + if (!state) { + return; + } await state.runtime.session.prompt(job.prompt, { streamingBehavior: state.runtime.session.isStreaming ? "followUp" : undefined, source: "rpc", @@ -418,6 +421,7 @@ export class AgentDaemon { sessionId: session.sessionId, sessionFile, cwd: state.runtime.cwd, + runtimeKind: state.runtime.metadata.kind, scheduleText: schedule, prompt, }); @@ -436,6 +440,7 @@ export class AgentDaemon { sessionId: session.sessionId, sessionFile, cwd: state.runtime.cwd, + runtimeKind: state.runtime.metadata.kind, scheduleText: normalizeHeartbeatSchedule(schedule), prompt: instruction, }); @@ -471,6 +476,7 @@ export class AgentDaemon { sessionId: session.sessionId, sessionFile, cwd: state.runtime.cwd, + runtimeKind: state.runtime.metadata.kind, label: input.label, scheduleText: normalizeHeartbeatSchedule(input.interval ?? DEFAULT_HEARTBEAT_SCHEDULE), prompt: input.instruction, @@ -519,12 +525,27 @@ export class AgentDaemon { } } - private async getOrCreateCronJobSession(job: AgentCronJob): Promise { + private cancelSubagentRlmHeartbeats(state: ActiveSessionState): void { + if (state.runtime.metadata.kind !== "subagent") { + return; + } + const cancelled = this.cronStore.cancelRlmHeartbeatsForSession(state.activeSessionId); + if (cancelled.length > 0) { + this.cronScheduler.wake(); + } + } + + private async getOrCreateCronJobSession(job: AgentCronJob): Promise { const current = this.sessions.get(job.activeSessionId) ?? this.findSessionBySessionFile(job.sessionFile); if (current) { this.rebindCronJobsToState(current); return current; } + if (job.source === "rlm_heartbeat" && job.runtimeKind === "subagent") { + this.cronStore.cancel(job.id); + this.cronScheduler.wake(); + return undefined; + } return this.createRuntime({ type: "create", sessionPath: job.sessionFile }); } @@ -1391,6 +1412,7 @@ export class AgentDaemon { if (!this.sessions.has(state.activeSessionId)) { return; } + this.cancelSubagentRlmHeartbeats(state); const cascadeError = await this.closeChildSessions(state, reason); let persistError: unknown; if (reason !== "shutdown") { diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 3994ceea65..d1e097b0b6 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -245,6 +245,68 @@ describe("AgentCronJobStore", () => { expect(store.getHeartbeat("other-active")).toMatchObject({ prompt: "check on a different session" }); }); + it("moves live session jobs to a replacement session file", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const cronJob = store.create({ + activeSessionId: "active-1", + sessionId: "old-session", + sessionFile: "/tmp/old-session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1h", + prompt: "continue the audit", + now: start, + }); + const userHeartbeat = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "old-session", + sessionFile: "/tmp/old-session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on the user", + now: start, + }); + const rlmHeartbeat = store.createRlmHeartbeat({ + activeSessionId: "active-1", + sessionId: "old-session", + sessionFile: "/tmp/old-session.jsonl", + cwd: "/tmp/project", + label: "review", + scheduleText: "every 10m", + prompt: "review the latest output", + now: start, + }); + + const rebound = store.rebindSessionJobs({ + activeSessionId: "active-1", + sessionId: "new-session", + sessionFile: "/tmp/new-session.jsonl", + cwd: "/tmp/project", + }); + + expect(rebound.map((job) => job.id)).toEqual( + expect.arrayContaining([cronJob.id, userHeartbeat.id, rlmHeartbeat.id]), + ); + expect(store.list().filter((job) => job.activeSessionId === "active-1")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: cronJob.id, + sessionId: "new-session", + sessionFile: "/tmp/new-session.jsonl", + }), + expect.objectContaining({ + id: userHeartbeat.id, + sessionId: "new-session", + sessionFile: "/tmp/new-session.jsonl", + }), + expect.objectContaining({ + id: rlmHeartbeat.id, + sessionId: "new-session", + sessionFile: "/tmp/new-session.jsonl", + }), + ]), + ); + }); + it("keeps multiple RLM heartbeats separate from the single user heartbeat", () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const userHeartbeat = store.createHeartbeat({ @@ -288,6 +350,56 @@ describe("AgentCronJobStore", () => { expect(store.getHeartbeat("active-1")).toMatchObject({ id: userHeartbeat.id, status: "active" }); }); + it("cancels active RLM heartbeats for a released session", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const active = store.createRlmHeartbeat({ + activeSessionId: "subagent-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + runtimeKind: "subagent", + label: "active", + scheduleText: "every 30s", + prompt: "continue active work", + now: start, + }); + const paused = store.createRlmHeartbeat({ + activeSessionId: "subagent-1", + sessionId: "session-rlm-1", + sessionFile: "/tmp/session-rlm.jsonl", + cwd: "/tmp/project", + runtimeKind: "subagent", + label: "paused", + scheduleText: "every 10m", + prompt: "continue paused work", + now: start, + }); + store.updateRlmHeartbeat("subagent-1", paused.id, { status: "pause", now: start }); + store.createRlmHeartbeat({ + activeSessionId: "top-level-1", + sessionId: "top-level-session", + sessionFile: "/tmp/top-level.jsonl", + cwd: "/tmp/project", + runtimeKind: "top-level", + label: "top-level", + scheduleText: "every 5m", + prompt: "continue top-level work", + now: start, + }); + + const cancelled = store.cancelRlmHeartbeatsForSession("subagent-1", new Date("2026-01-01T12:40:00.000Z")); + + expect(cancelled.map((job) => job.id)).toEqual(expect.arrayContaining([active.id, paused.id])); + expect(store.listRlmHeartbeats("subagent-1")).toEqual([]); + expect(store.listRlmHeartbeats("subagent-1", { includeInactive: true })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: active.id, status: "cancelled" }), + expect.objectContaining({ id: paused.id, status: "cancelled" }), + ]), + ); + expect(store.listRlmHeartbeats("top-level-1")[0]).toMatchObject({ status: "active" }); + }); + it("returns undefined when updating inactive RLM heartbeats", () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const rlmHeartbeat = store.createRlmHeartbeat({ From 50397c520a92ab20170a7d52cd5e32f2adc7692a Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 13:57:58 -0700 Subject: [PATCH 08/17] fix(coding-agent): skip cancelled cron jobs during run --- packages/coding-agent/src/core/cron-jobs.ts | 18 ++++++-- packages/coding-agent/test/cron-jobs.test.ts | 43 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 11e330d9ab..e77f7d7dff 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -470,9 +470,11 @@ export class AgentCronJobStore { } due(now = new Date()): AgentCronJob[] { - return this.readJobs().filter((job) => { - return job.status === "active" && job.nextRunAt !== undefined && Date.parse(job.nextRunAt) <= now.getTime(); - }); + return this.readJobs().filter((job) => isDueJob(job, now)); + } + + getDueJob(id: string, now = new Date()): AgentCronJob | undefined { + return this.readJobs().find((job) => job.id === id && isDueJob(job, now)); } nextActiveRunAt(): Date | undefined { @@ -540,7 +542,11 @@ export class AgentCronScheduler { this.running = true; let handled = 0; try { - for (const job of this.store.due(now)) { + for (const dueJob of this.store.due(now)) { + const job = this.store.getDueJob(dueJob.id, now); + if (!job) { + continue; + } handled++; let error: unknown; try { @@ -898,6 +904,10 @@ function stripMatchingQuotes(value: string): string { return value; } +function isDueJob(job: AgentCronJob, now: Date): boolean { + return job.status === "active" && job.nextRunAt !== undefined && Date.parse(job.nextRunAt) <= now.getTime(); +} + function compareOptionalIso(left: string | undefined, right: string | undefined): number { if (left === right) { return 0; diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index d1e097b0b6..984cd921c5 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -621,6 +621,49 @@ describe("AgentCronScheduler", () => { runCount: 0, }); }); + + it("skips jobs cancelled while earlier due jobs are running", async () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const first = store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1m", + prompt: "first", + now: start, + }); + const second = store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1m", + prompt: "second", + now: start, + }); + const prompts: string[] = []; + const scheduler = new AgentCronScheduler(store, { + now: () => new Date("2026-01-01T12:35:00.000Z"), + runJob: async (dueJob) => { + prompts.push(dueJob.prompt); + if (dueJob.id === first.id) { + store.cancel(second.id, new Date("2026-01-01T12:35:00.000Z")); + } + }, + }); + + const handled = await scheduler.runDue(new Date("2026-01-01T12:35:00.000Z")); + + expect(handled).toBe(1); + expect(prompts).toEqual(["first"]); + expect(store.list()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: first.id, status: "completed", runCount: 1 }), + expect.objectContaining({ id: second.id, status: "cancelled", runCount: 0 }), + ]), + ); + }); }); describe("createAgentHeartbeatToolDefinitions", () => { From a9c515f62b458319716c42ee62ef591afe597226 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 13:59:04 -0700 Subject: [PATCH 09/17] fix(coding-agent): correct cron job persistence error --- packages/coding-agent/src/modes/daemon/daemon-mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 53a470bdde..4d4ec68e5f 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -414,7 +414,7 @@ export class AgentDaemon { const session = state.runtime.session; const sessionFile = session.sessionFile; if (!sessionFile) { - throw new Error("Heartbeats require a persisted session file"); + throw new Error("Cron jobs require a persisted session file"); } const job = this.cronStore.create({ activeSessionId: state.activeSessionId, From bd1398d08993e6d1996da867155b163b344beb81 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 14:07:35 -0700 Subject: [PATCH 10/17] fix(coding-agent): dedupe daemon session creation --- .../src/modes/daemon/daemon-mode.ts | 144 +++++++++++------- .../coding-agent/test/daemon-mode.test.ts | 72 +++++++++ 2 files changed, 158 insertions(+), 58 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 4d4ec68e5f..2aafafdd37 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -172,6 +172,7 @@ export class AgentDaemon { private ownsSocketPath = false; private readonly clients = new Set(); private readonly sessions = new Map(); + private readonly openingSessions = new Map>(); private readonly closingSessions = new Map>(); private readonly signalCleanupHandlers: Array<() => void> = []; private readonly cronStore: AgentCronJobStore; @@ -325,6 +326,7 @@ export class AgentDaemon { } const cwd = resolve(config.cwd); + const agentDir = config.agentDir; const cwdOverride = command.config?.cwd ? resolve(command.config.cwd) : undefined; const sessionPath = command.sessionPath ? await resolveDaemonSessionPath(command.sessionPath, cwd, config.sessionDir) @@ -334,69 +336,95 @@ export class AgentDaemon { : command.continueRecent ? SessionManager.continueRecent(cwd, config.sessionDir) : SessionManager.create(cwd, config.sessionDir); - const existing = this.findSessionBySessionFile(sessionManager.getSessionFile()); - if (existing) { - // A live runtime already owns this session file; reuse it instead of - // starting a second runtime that would interleave writes to one file. - if (command.name) { - existing.runtime.session.setSessionName(command.name); - } - this.rebindCronJobsToState(existing); - return existing; - } - if ((sessionPath || command.continueRecent) && sessionManager.getSessionState()?.status === "hidden") { - // Resuming a hidden session is the intentional opt-in that makes it - // visible in session lists again. - sessionManager.appendSessionState({ status: "sleep" }); - } - let stateRef: ActiveSessionState | undefined; - const runtime = await createAgentSessionRuntime(this.options.createRuntime, { - cwd: sessionManager.getCwd(), - agentDir: config.agentDir, - sessionManager, - sessionConfig: config, - sessionOptions: { - customTools: [ - ...createAgentHeartbeatToolDefinitions({ - getHeartbeat: () => { + const createState = async (): Promise => { + const existing = this.findSessionBySessionFile(sessionManager.getSessionFile()); + if (existing) { + // A live runtime already owns this session file; reuse it instead of + // starting a second runtime that would interleave writes to one file. + if (command.name) { + existing.runtime.session.setSessionName(command.name); + } + this.rebindCronJobsToState(existing); + return existing; + } + if ((sessionPath || command.continueRecent) && sessionManager.getSessionState()?.status === "hidden") { + // Resuming a hidden session is the intentional opt-in that makes it + // visible in session lists again. + sessionManager.appendSessionState({ status: "sleep" }); + } + let stateRef: ActiveSessionState | undefined; + const runtime = await createAgentSessionRuntime(this.options.createRuntime, { + cwd: sessionManager.getCwd(), + agentDir, + sessionManager, + sessionConfig: config, + sessionOptions: { + customTools: [ + ...createAgentHeartbeatToolDefinitions({ + getHeartbeat: () => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.cronStore.getHeartbeat(stateRef.activeSessionId); + }, + }), + ], + rlmHeartbeatController: { + listRlmHeartbeats: (listOptions) => { if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); + throw new Error("RLM heartbeat state is not ready for this session yet"); } - return this.cronStore.getHeartbeat(stateRef.activeSessionId); + return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); + }, + createRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.createRlmHeartbeatForState(stateRef, input); + }, + updateRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.updateRlmHeartbeatForState(stateRef, input); + }, + deleteRlmHeartbeat: (id) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.deleteRlmHeartbeatForState(stateRef, id); }, - }), - ], - rlmHeartbeatController: { - listRlmHeartbeats: (listOptions) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); - }, - createRlmHeartbeat: (input) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.createRlmHeartbeatForState(stateRef, input); - }, - updateRlmHeartbeat: (input) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.updateRlmHeartbeatForState(stateRef, input); - }, - deleteRlmHeartbeat: (id) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.deleteRlmHeartbeatForState(stateRef, id); }, }, - }, - }); - const state = await this.addRuntime(runtime, command.name); - stateRef = state; - return state; + }); + const state = await this.addRuntime(runtime, command.name); + stateRef = state; + return state; + }; + + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) { + return createState(); + } + const sessionKey = resolve(sessionFile); + const pending = this.openingSessions.get(sessionKey); + if (pending) { + const state = await pending; + if (command.name) { + state.runtime.session.setSessionName(command.name); + } + this.rebindCronJobsToState(state); + return state; + } + const opening = Promise.resolve().then(createState); + this.openingSessions.set(sessionKey, opening); + try { + return await opening; + } finally { + if (this.openingSessions.get(sessionKey) === opening) { + this.openingSessions.delete(sessionKey); + } + } } private async runCronJob(job: AgentCronJob): Promise { diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 335ccd8b31..c0608226f5 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -1,5 +1,9 @@ +import { mkdtempSync, rmSync } from "node:fs"; import type { Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { CreateAgentSessionRuntimeFactory } from "../src/core/agent-session-runtime.js"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; import { AgentDaemon, @@ -86,6 +90,58 @@ describe("daemon mode helpers", () => { ).toBe(true); }); + it("deduplicates concurrent creates for the same session file", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-race-")); + try { + const sessionPath = join(tempDir, "session.jsonl"); + let releaseCreate: () => void = () => {}; + const createBarrier = new Promise((resolve) => { + releaseCreate = resolve; + }); + const createRuntime = vi.fn(async (options: Parameters[0]) => { + await createBarrier; + return { + session: makeRuntimeSession(options.sessionManager), + extensionsResult: { extensions: [], errors: [], runtime: {} } as unknown as Awaited< + ReturnType + >["extensionsResult"], + services: { cwd: options.cwd, agentDir: options.agentDir } as Awaited< + ReturnType + >["services"], + diagnostics: [], + }; + }); + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { + agentDir: tempDir, + cwd: tempDir, + sessionDir: tempDir, + }, + createRuntime, + }); + const create = ( + daemon as unknown as { + createRuntime(command: Extract): Promise; + } + ).createRuntime.bind(daemon); + + const first = create({ type: "create", sessionPath }); + const second = create({ type: "create", sessionPath }); + for (let attempt = 0; attempt < 20 && createRuntime.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } + + expect(createRuntime).toHaveBeenCalledTimes(1); + releaseCreate(); + const [firstState, secondState] = await Promise.all([first, second]); + + expect(secondState).toBe(firstState); + expect(createRuntime).toHaveBeenCalledTimes(1); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("validates active sessions before reading a heartbeat", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { @@ -112,6 +168,22 @@ describe("daemon mode helpers", () => { }); }); +function makeRuntimeSession( + sessionManager: Parameters[0]["sessionManager"], +): Awaited>["session"] { + return { + sessionManager, + sessionFile: sessionManager.getSessionFile(), + sessionId: sessionManager.getSessionId(), + setSubagentRuntimeHost: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + bindExtensions: vi.fn(async () => {}), + setSessionName: vi.fn(), + dispose: vi.fn(), + abort: vi.fn(async () => {}), + } as unknown as Awaited>["session"]; +} + function makeState(activeSessionId: string, parentActiveSessionId?: string): ActiveSessionState { return { activeSessionId, From dc9b39519ba91270aeffaf50b0d8aa982ef3efdd Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 14:09:04 -0700 Subject: [PATCH 11/17] fix(coding-agent): start cron after daemon restore --- packages/coding-agent/src/modes/daemon/daemon-mode.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 2aafafdd37..72c6b1ed40 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -230,8 +230,11 @@ export class AgentDaemon { this.registerSignalHandlers(); console.error(`Prime Agent daemon listening on ${this.socketPath}`); - void this.restoreActiveSessions(); - this.cronScheduler.start(); + void this.restoreActiveSessions().finally(() => { + if (!this.shuttingDown) { + this.cronScheduler.start(); + } + }); } /** From 3484cfcbf16917f1cbc169288102ed582f54a506 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 14:10:37 -0700 Subject: [PATCH 12/17] fix(coding-agent): list paused cron jobs by default --- .../src/modes/daemon/daemon-mode.ts | 2 +- .../coding-agent/test/daemon-mode.test.ts | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 72c6b1ed40..7e85823616 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1064,7 +1064,7 @@ export class AgentDaemon { case "cron_list": { const jobs = this.cronStore.list().filter((job) => { - if (!command.includeInactive && job.status !== "active") { + if (!command.includeInactive && job.status !== "active" && job.status !== "paused") { return false; } if (command.activeSessionId && job.activeSessionId !== command.activeSessionId) { diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index c0608226f5..aee58f3858 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { CreateAgentSessionRuntimeFactory } from "../src/core/agent-session-runtime.js"; +import type { AgentCronJob, AgentCronJobStore } from "../src/core/cron-jobs.js"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; import { AgentDaemon, @@ -142,6 +143,44 @@ describe("daemon mode helpers", () => { } }); + it("includes paused jobs in the default cron list", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-cron-list-")); + try { + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { + agentDir: tempDir, + cwd: tempDir, + }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const internals = daemon as unknown as { + cronStore: AgentCronJobStore; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + const heartbeat = internals.cronStore.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: join(tempDir, "session.jsonl"), + cwd: tempDir, + scheduleText: "every 5m", + prompt: "check on the session", + now: new Date("2026-01-01T12:00:00.000Z"), + }); + internals.cronStore.pauseHeartbeat("active-1", new Date("2026-01-01T12:01:00.000Z")); + + const response = (await internals.handleCommand(makeClient("client-1", "active-1"), { + id: "command-1", + type: "cron_list", + })) as { data: { jobs: AgentCronJob[] } }; + + expect(response.data.jobs).toEqual([expect.objectContaining({ id: heartbeat.id, status: "paused" })]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("validates active sessions before reading a heartbeat", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { From 1f4ac2abc7ba4302fad28380352d22029ee416be Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 14:17:58 -0700 Subject: [PATCH 13/17] fix(coding-agent): preserve concurrent cron job writes --- packages/coding-agent/src/core/cron-jobs.ts | 29 ++++++++- packages/coding-agent/test/cron-jobs.test.ts | 63 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index e77f7d7dff..650ccbab42 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -499,8 +499,9 @@ export class AgentCronJobStore { private writeJobs(jobs: readonly AgentCronJob[]): void { mkdirSync(dirname(this.filePath), { recursive: true }); + const mergedJobs = mergeFreshJobs(this.readJobs(), jobs); const tempPath = `${this.filePath}.tmp`; - writeFileSync(tempPath, `${JSON.stringify({ jobs }, null, 2)}\n`, "utf-8"); + writeFileSync(tempPath, `${JSON.stringify({ jobs: mergedJobs }, null, 2)}\n`, "utf-8"); renameSync(tempPath, this.filePath); } } @@ -908,6 +909,32 @@ function isDueJob(job: AgentCronJob, now: Date): boolean { return job.status === "active" && job.nextRunAt !== undefined && Date.parse(job.nextRunAt) <= now.getTime(); } +function mergeFreshJobs(currentJobs: readonly AgentCronJob[], nextJobs: readonly AgentCronJob[]): AgentCronJob[] { + const merged = new Map(); + for (const job of currentJobs) { + merged.set(job.id, job); + } + for (const job of nextJobs) { + const current = merged.get(job.id); + if (!current || isAtLeastAsFresh(job, current)) { + merged.set(job.id, job); + } + } + return [...merged.values()]; +} + +function isAtLeastAsFresh(candidate: AgentCronJob, current: AgentCronJob): boolean { + const candidateTime = Date.parse(candidate.updatedAt); + const currentTime = Date.parse(current.updatedAt); + if (!Number.isFinite(currentTime)) { + return true; + } + if (!Number.isFinite(candidateTime)) { + return false; + } + return candidateTime >= currentTime; +} + function compareOptionalIso(left: string | undefined, right: string | undefined): number { if (left === right) { return 0; diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 984cd921c5..650ecbe959 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + type AgentCronJob, AgentCronJobStore, AgentCronScheduler, createAgentHeartbeatToolDefinitions, @@ -122,6 +123,60 @@ describe("AgentCronJobStore", () => { expect(store.nextActiveRunAt()?.toISOString()).toBe("2026-01-01T12:35:00.000Z"); }); + it("preserves concurrent cron store writes when a stale snapshot is written", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const first = store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 1h", + prompt: "first", + now: start, + }); + const staleSnapshot = [first]; + const second = store.create({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "in 2h", + prompt: "second", + now: new Date("2026-01-01T12:35:00.000Z"), + }); + + writeJobsForTest(store, staleSnapshot); + + expect(store.list()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: first.id, prompt: "first" }), + expect.objectContaining({ id: second.id, prompt: "second" }), + ]), + ); + }); + + it("keeps newer cron store state when a stale snapshot is written", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const heartbeat = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on me", + now: start, + }); + store.pauseHeartbeat("active-1", new Date("2026-01-01T12:35:00.000Z")); + + writeJobsForTest(store, [heartbeat]); + + expect(store.getHeartbeat("active-1")).toMatchObject({ + id: heartbeat.id, + status: "paused", + updatedAt: "2026-01-01T12:35:00.000Z", + }); + }); + it("keeps one persistent heartbeat per active session", () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const first = store.createHeartbeat({ @@ -705,6 +760,14 @@ describe("createAgentHeartbeatToolDefinitions", () => { }); }); +function writeJobsForTest(store: AgentCronJobStore, jobs: readonly AgentCronJob[]): void { + ( + store as unknown as { + writeJobs(jobs: readonly AgentCronJob[]): void; + } + ).writeJobs(jobs); +} + function makeStorePath(tempDirs: string[]): string { const dir = mkdtempSync(join(tmpdir(), "prime-agent-cron-")); tempDirs.push(dir); From 1137f14191303b7323f9a49ff301be2a7db98682 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 15:53:41 -0700 Subject: [PATCH 14/17] fix(coding-agent): coalesce heartbeat follow-ups --- .../coding-agent/src/core/agent-session.ts | 39 +++++-- .../src/modes/daemon/daemon-mode.ts | 10 ++ .../coding-agent/test/daemon-mode.test.ts | 110 ++++++++++++++++++ .../test/suite/agent-session-queue.test.ts | 51 ++++++++ 4 files changed, 200 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 620ed72e6a..e440b17e0f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -350,12 +350,19 @@ export interface PromptOptions { images?: ImageContent[]; /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ streamingBehavior?: "steer" | "followUp"; + /** Coalesce follow-up queueing so only one pending follow-up exists for this key. */ + followUpQueueKey?: string; /** Source of input for extension input event handlers. Defaults to "interactive". */ source?: InputSource; /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */ preflightResult?: (success: boolean) => void; } +interface QueuedFollowUpMessage { + text: string; + queueKey?: string; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -649,7 +656,7 @@ export class AgentSession { /** Tracks pending steering messages for UI display. Removed when delivered. */ private _steeringMessages: string[] = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ - private _followUpMessages: string[] = []; + private _followUpMessages: QueuedFollowUpMessage[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ private _pendingNextTurnMessages: CustomMessage[] = []; @@ -884,7 +891,7 @@ export class AgentSession { this._emit({ type: "queue_update", steering: [...this._steeringMessages], - followUp: [...this._followUpMessages], + followUp: this._followUpMessages.map((message) => message.text), }); } @@ -1553,7 +1560,7 @@ export class AgentSession { this._emitQueueUpdate(); } else { // Check follow-up queue - const followUpIndex = this._followUpMessages.indexOf(messageText); + const followUpIndex = this._followUpMessages.findIndex((message) => message.text === messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); this._emitQueueUpdate(); @@ -2105,7 +2112,7 @@ export class AgentSession { ); } if (options.streamingBehavior === "followUp") { - await this._queueFollowUp(expandedText, currentImages); + await this._queueFollowUp(expandedText, currentImages, { queueKey: options.followUpQueueKey }); } else { await this._queueSteer(expandedText, currentImages); } @@ -2288,7 +2295,7 @@ export class AgentSession { * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ - async followUp(text: string, images?: ImageContent[]): Promise { + async followUp(text: string, images?: ImageContent[], options: { queueKey?: string } = {}): Promise { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); @@ -2298,7 +2305,7 @@ export class AgentSession { let expandedText = this._expandSkillCommand(text); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); - await this._queueFollowUp(expandedText, images); + await this._queueFollowUp(expandedText, images, { queueKey: options.queueKey }); } /** @@ -2321,8 +2328,15 @@ export class AgentSession { /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ - private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { - this._followUpMessages.push(text); + private async _queueFollowUp( + text: string, + images?: ImageContent[], + options: { queueKey?: string } = {}, + ): Promise { + if (options.queueKey && this._followUpMessages.some((message) => message.queueKey === options.queueKey)) { + return false; + } + this._followUpMessages.push({ text, queueKey: options.queueKey }); this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { @@ -2333,6 +2347,7 @@ export class AgentSession { content, timestamp: Date.now(), }); + return true; } /** @@ -2444,7 +2459,7 @@ export class AgentSession { */ clearQueue(): { steering: string[]; followUp: string[] } { const steering = [...this._steeringMessages]; - const followUp = [...this._followUpMessages]; + const followUp = this._followUpMessages.map((message) => message.text); this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); @@ -2464,7 +2479,11 @@ export class AgentSession { /** Get pending follow-up messages (read-only) */ getFollowUpMessages(): readonly string[] { - return this._followUpMessages; + return this._followUpMessages.map((message) => message.text); + } + + hasQueuedFollowUp(queueKey: string): boolean { + return this._followUpMessages.some((message) => message.queueKey === queueKey); } get resourceLoader(): ResourceLoader { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 576e3ada87..696a1e7728 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -474,8 +474,14 @@ export class AgentDaemon { if (!state) { return; } + const followUpQueueKey = isHeartbeatCronJob(job) ? `heartbeat:${job.id}` : undefined; + if (followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { + await state.runtime.session.followUp(job.prompt, undefined, { queueKey: followUpQueueKey }); + return; + } await state.runtime.session.prompt(job.prompt, { streamingBehavior: state.runtime.session.isStreaming ? "followUp" : undefined, + followUpQueueKey, source: "rpc", }); } @@ -1624,6 +1630,10 @@ export class AgentDaemon { } } +function isHeartbeatCronJob(job: AgentCronJob): boolean { + return job.source === "heartbeat" || job.source === "rlm_heartbeat"; +} + function serializeSavedSessionInfo(session: SessionInfo): DaemonSavedSessionInfo { return { path: session.path, diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index aee58f3858..56c24a221e 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -181,6 +181,98 @@ describe("daemon mode helpers", () => { } }); + it("queues busy heartbeat cron jobs with a per-job coalescing key", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { + agentDir: "/tmp/prime-agent-test-agent", + cwd: "/tmp", + }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => {}); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { + isStreaming: true, + pendingMessageCount: 0, + prompt, + followUp, + } as never; + ( + daemon as unknown as { + sessions: Map; + } + ).sessions.set(state.activeSessionId, state); + const runCronJob = ( + daemon as unknown as { + runCronJob(job: AgentCronJob): Promise; + } + ).runCronJob.bind(daemon); + + await runCronJob(makeCronJob({ id: "heartbeat-1", source: "heartbeat", activeSessionId: state.activeSessionId })); + + expect(followUp).toHaveBeenCalledWith("heartbeat prompt", undefined, { queueKey: "heartbeat:heartbeat-1" }); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("uses separate queue keys for separate RLM heartbeat cron jobs", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { + agentDir: "/tmp/prime-agent-test-agent", + cwd: "/tmp", + }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => {}); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { + isStreaming: true, + pendingMessageCount: 0, + prompt, + followUp, + } as never; + ( + daemon as unknown as { + sessions: Map; + } + ).sessions.set(state.activeSessionId, state); + const runCronJob = ( + daemon as unknown as { + runCronJob(job: AgentCronJob): Promise; + } + ).runCronJob.bind(daemon); + + await runCronJob(makeCronJob({ id: "rlm-1", source: "rlm_heartbeat", activeSessionId: state.activeSessionId })); + await runCronJob(makeCronJob({ id: "rlm-2", source: "rlm_heartbeat", activeSessionId: state.activeSessionId })); + + expect(followUp).toHaveBeenNthCalledWith(1, "heartbeat prompt", undefined, { queueKey: "heartbeat:rlm-1" }); + expect(followUp).toHaveBeenNthCalledWith(2, "heartbeat prompt", undefined, { queueKey: "heartbeat:rlm-2" }); + expect(prompt).not.toHaveBeenCalled(); + }); + it("validates active sessions before reading a heartbeat", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { @@ -207,6 +299,24 @@ describe("daemon mode helpers", () => { }); }); +function makeCronJob(input: { id: string; source: AgentCronJob["source"]; activeSessionId: string }): AgentCronJob { + return { + id: input.id, + status: "active", + source: input.source, + activeSessionId: input.activeSessionId, + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp", + prompt: "heartbeat prompt", + schedule: { kind: "interval", expression: "every 5m", intervalMs: 300_000 }, + createdAt: "2026-01-01T12:00:00.000Z", + updatedAt: "2026-01-01T12:00:00.000Z", + nextRunAt: "2026-01-01T12:05:00.000Z", + runCount: 0, + }; +} + function makeRuntimeSession( sessionManager: Parameters[0]["sessionManager"], ): Awaited>["session"] { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 26fa6b6968..0fb808eea5 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -122,6 +122,57 @@ describe("AgentSession queue characterization", () => { expect(getAssistantTexts(harness)).toContain("saw steer"); }); + it("coalesces follow-up messages with the same queue key", async () => { + const waiting = await createWaitingHarness(); + const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; + harnesses.push(harness); + + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + await waitForToolStart; + await harness.session.prompt("heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + }); + await harness.session.prompt("heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + }); + + expect(harness.session.getFollowUpMessages()).toEqual(["heartbeat"]); + + releaseToolExecution(); + await promptPromise; + }); + + it("keeps separate follow-up messages for different queue keys", async () => { + const waiting = await createWaitingHarness(); + const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; + harnesses.push(harness); + + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("first done"), + fauxAssistantMessage("second done"), + ]); + await waitForToolStart; + await harness.session.prompt("heartbeat one", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + }); + await harness.session.prompt("heartbeat two", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:two", + }); + + expect(harness.session.getFollowUpMessages()).toEqual(["heartbeat one", "heartbeat two"]); + + releaseToolExecution(); + await promptPromise; + }); + it("delivers follow-up messages only after the current run finishes", async () => { const waiting = await createWaitingHarness(); const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; From 7ddb1a8ad232e1772b1c52b566c330cab74e3e27 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 15:58:31 -0700 Subject: [PATCH 15/17] fix(coding-agent): coalesce queued heartbeat runs --- .../coding-agent/src/core/agent-session.ts | 4 +- packages/coding-agent/src/core/cron-jobs.ts | 34 +++++++++- .../src/modes/daemon/daemon-mode.ts | 8 ++- packages/coding-agent/test/cron-jobs.test.ts | 35 ++++++++++- .../coding-agent/test/daemon-mode.test.ts | 62 ++++++++++++++++++- 5 files changed, 132 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e440b17e0f..b4bdcc0b28 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2295,7 +2295,7 @@ export class AgentSession { * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ - async followUp(text: string, images?: ImageContent[], options: { queueKey?: string } = {}): Promise { + async followUp(text: string, images?: ImageContent[], options: { queueKey?: string } = {}): Promise { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); @@ -2305,7 +2305,7 @@ export class AgentSession { let expandedText = this._expandSkillCommand(text); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); - await this._queueFollowUp(expandedText, images, { queueKey: options.queueKey }); + return this._queueFollowUp(expandedText, images, { queueKey: options.queueKey }); } /** diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 650ccbab42..95bf322bac 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -50,8 +50,10 @@ export interface CreateAgentCronJobInput { now?: Date; } +export type AgentCronJobRunResult = "ran" | "skipped"; + export interface AgentCronSchedulerHooks { - runJob: (job: AgentCronJob) => Promise; + runJob: (job: AgentCronJob) => Promise; now?: () => Date; onError?: (job: AgentCronJob, error: unknown) => void; } @@ -469,6 +471,27 @@ export class AgentCronJobStore { return updated; } + recordSkipResult(id: string, result: { now?: Date }): AgentCronJob | undefined { + const now = result.now ?? new Date(); + let updated: AgentCronJob | undefined; + const jobs = this.readJobs().map((job) => { + if (job.id !== id) { + return job; + } + if (job.status !== "active") { + updated = job; + return job; + } + const nextRunAt = nextRunAtForSchedule(job.schedule, now); + updated = { ...job, nextRunAt: nextRunAt?.toISOString(), updatedAt: now.toISOString() }; + return updated; + }); + if (updated) { + this.writeJobs(jobs); + } + return updated; + } + due(now = new Date()): AgentCronJob[] { return this.readJobs().filter((job) => isDueJob(job, now)); } @@ -548,14 +571,19 @@ export class AgentCronScheduler { if (!job) { continue; } - handled++; + let runResult: AgentCronJobRunResult | undefined; let error: unknown; try { - await this.hooks.runJob(job); + runResult = await this.hooks.runJob(job); } catch (runError) { error = runError; this.hooks.onError?.(job, runError); } + if (runResult === "skipped" && error === undefined) { + this.store.recordSkipResult(job.id, { now: this.now() }); + continue; + } + handled++; this.store.recordRunResult(job.id, { now: this.now(), error }); } } finally { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 696a1e7728..654ef32d4e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -469,14 +469,18 @@ export class AgentDaemon { } } - private async runCronJob(job: AgentCronJob): Promise { + private async runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> { const state = await this.getOrCreateCronJobSession(job); if (!state) { return; } const followUpQueueKey = isHeartbeatCronJob(job) ? `heartbeat:${job.id}` : undefined; if (followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { - await state.runtime.session.followUp(job.prompt, undefined, { queueKey: followUpQueueKey }); + const didQueue = await state.runtime.session.followUp(job.prompt, undefined, { queueKey: followUpQueueKey }); + return didQueue ? undefined : "skipped"; + } + if (!followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { + await state.runtime.session.followUp(job.prompt); return; } await state.runtime.session.prompt(job.prompt, { diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 650ecbe959..0f355051e3 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -577,6 +577,7 @@ describe("AgentCronScheduler", () => { now: () => new Date("2026-01-01T12:35:00.000Z"), runJob: async (dueJob) => { prompts.push(dueJob.prompt); + return undefined; }, }); @@ -605,7 +606,7 @@ describe("AgentCronScheduler", () => { }); const scheduler = new AgentCronScheduler(store, { now: () => new Date("2026-01-01T12:35:00.000Z"), - runJob: async () => {}, + runJob: async () => undefined, }); await scheduler.runDue(new Date("2026-01-01T12:35:00.000Z")); @@ -631,7 +632,7 @@ describe("AgentCronScheduler", () => { }); const scheduler = new AgentCronScheduler(store, { now: () => new Date("2026-01-01T12:34:30.000Z"), - runJob: async () => {}, + runJob: async () => undefined, }); await scheduler.runDue(new Date("2026-01-01T12:34:30.000Z")); @@ -662,6 +663,7 @@ describe("AgentCronScheduler", () => { now: () => new Date("2026-01-01T12:34:31.000Z"), runJob: async (dueJob) => { prompts.push(dueJob.prompt); + return undefined; }, }); @@ -677,6 +679,34 @@ describe("AgentCronScheduler", () => { }); }); + it("reschedules skipped jobs without recording a run", async () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const job = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on me", + now: start, + }); + const scheduler = new AgentCronScheduler(store, { + now: () => new Date("2026-01-01T12:40:00.000Z"), + runJob: async () => "skipped", + }); + + const handled = await scheduler.runDue(new Date("2026-01-01T12:39:00.000Z")); + + expect(handled).toBe(0); + expect(store.getHeartbeat("active-1")).toMatchObject({ + id: job.id, + status: "active", + nextRunAt: "2026-01-01T12:45:00.000Z", + runCount: 0, + }); + expect(store.getHeartbeat("active-1")).not.toHaveProperty("lastRunAt"); + }); + it("skips jobs cancelled while earlier due jobs are running", async () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const first = store.create({ @@ -705,6 +735,7 @@ describe("AgentCronScheduler", () => { if (dueJob.id === first.id) { store.cancel(second.id, new Date("2026-01-01T12:35:00.000Z")); } + return undefined; }, }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 56c24a221e..e9ca934619 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -192,7 +192,7 @@ describe("daemon mode helpers", () => { }, }); const prompt = vi.fn(async () => {}); - const followUp = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); const state = makeState("active-1") as ActiveSessionState & { runtime: ActiveSessionState["runtime"] & { session: { @@ -237,7 +237,7 @@ describe("daemon mode helpers", () => { }, }); const prompt = vi.fn(async () => {}); - const followUp = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); const state = makeState("active-1") as ActiveSessionState & { runtime: ActiveSessionState["runtime"] & { session: { @@ -273,6 +273,64 @@ describe("daemon mode helpers", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("skips duplicate queued heartbeat cron jobs", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + pendingMessageCount: number; + prompt: ReturnType; + followUp: ReturnType; + }; + }; + }; + const followUp = vi.fn(async () => false); + state.runtime.session = { isStreaming: true, pendingMessageCount: 1, prompt: vi.fn(), followUp } as never; + (daemon as unknown as { sessions: Map }).sessions.set(state.activeSessionId, state); + const result = await ( + daemon as unknown as { runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> } + ).runCronJob(makeCronJob({ id: "heartbeat-1", source: "heartbeat", activeSessionId: state.activeSessionId })); + + expect(result).toBe("skipped"); + expect(followUp).toHaveBeenCalledWith("heartbeat prompt", undefined, { queueKey: "heartbeat:heartbeat-1" }); + }); + + it("queues generic cron jobs behind pending messages", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const prompt = vi.fn(async () => {}); + const followUp = vi.fn(async () => true); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + isStreaming: boolean; + pendingMessageCount: number; + prompt: typeof prompt; + followUp: typeof followUp; + }; + }; + }; + state.runtime.session = { isStreaming: false, pendingMessageCount: 1, prompt, followUp } as never; + (daemon as unknown as { sessions: Map }).sessions.set(state.activeSessionId, state); + + await (daemon as unknown as { runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> }).runCronJob( + makeCronJob({ id: "cron-1", source: "cron", activeSessionId: state.activeSessionId }), + ); + + expect(followUp).toHaveBeenCalledWith("heartbeat prompt"); + expect(prompt).not.toHaveBeenCalled(); + }); + it("validates active sessions before reading a heartbeat", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { From 308964e18a11d72cb37d7e29b3341c0f66d07d06 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 16:01:22 -0700 Subject: [PATCH 16/17] fix(coding-agent): handle duplicate queued heartbeats --- .../coding-agent/src/core/agent-session.ts | 14 +++++++++++ .../src/modes/daemon/daemon-mode.ts | 3 +++ .../coding-agent/test/daemon-mode.test.ts | 10 +++++++- .../test/suite/agent-session-queue.test.ts | 23 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b4bdcc0b28..a22cee508f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2486,6 +2486,20 @@ export class AgentSession { return this._followUpMessages.some((message) => message.queueKey === queueKey); } + removeQueuedFollowUp(queueKey: string): boolean { + const removed = this._followUpMessages.filter((message) => message.queueKey === queueKey); + if (removed.length === 0) { + return false; + } + this._followUpMessages = this._followUpMessages.filter((message) => message.queueKey !== queueKey); + const removedTexts = new Set(removed.map((message) => message.text)); + this.agent.removeQueuedMessages((message) => { + return message.role === "user" && removedTexts.has(this._getUserMessageText(message) ?? ""); + }); + this._emitQueueUpdate(); + return true; + } + get resourceLoader(): ResourceLoader { return this._resourceLoader; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 654ef32d4e..5d37a556f2 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -477,6 +477,9 @@ export class AgentDaemon { const followUpQueueKey = isHeartbeatCronJob(job) ? `heartbeat:${job.id}` : undefined; if (followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { const didQueue = await state.runtime.session.followUp(job.prompt, undefined, { queueKey: followUpQueueKey }); + if (!didQueue) { + state.runtime.session.removeQueuedFollowUp(followUpQueueKey); + } return didQueue ? undefined : "skipped"; } if (!followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index e9ca934619..469859a079 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -291,7 +291,14 @@ describe("daemon mode helpers", () => { }; }; const followUp = vi.fn(async () => false); - state.runtime.session = { isStreaming: true, pendingMessageCount: 1, prompt: vi.fn(), followUp } as never; + const removeQueuedFollowUp = vi.fn(() => true); + state.runtime.session = { + isStreaming: true, + pendingMessageCount: 1, + prompt: vi.fn(), + followUp, + removeQueuedFollowUp, + } as never; (daemon as unknown as { sessions: Map }).sessions.set(state.activeSessionId, state); const result = await ( daemon as unknown as { runCronJob(job: AgentCronJob): Promise<"skipped" | undefined> } @@ -299,6 +306,7 @@ describe("daemon mode helpers", () => { expect(result).toBe("skipped"); expect(followUp).toHaveBeenCalledWith("heartbeat prompt", undefined, { queueKey: "heartbeat:heartbeat-1" }); + expect(removeQueuedFollowUp).toHaveBeenCalledWith("heartbeat:heartbeat-1"); }); it("queues generic cron jobs behind pending messages", async () => { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 0fb808eea5..6e15981ae4 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -173,6 +173,29 @@ describe("AgentSession queue characterization", () => { await promptPromise; }); + it("removes coalesced follow-up messages by queue key", async () => { + const waiting = await createWaitingHarness(); + const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; + harnesses.push(harness); + + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + await waitForToolStart; + await harness.session.prompt("heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + }); + + expect(harness.session.removeQueuedFollowUp("heartbeat:one")).toBe(true); + expect(harness.session.getFollowUpMessages()).toEqual([]); + + releaseToolExecution(); + await promptPromise; + expect(getUserTexts(harness)).toEqual(["start"]); + }); + it("delivers follow-up messages only after the current run finishes", async () => { const waiting = await createWaitingHarness(); const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; From b4c99f5b9a091e9c76eff544f9ee01a29a659fca Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 17 Jun 2026 16:06:16 -0700 Subject: [PATCH 17/17] fix(coding-agent): preserve queued heartbeat delivery --- .../coding-agent/src/core/agent-session.ts | 16 +++--- .../src/modes/daemon/daemon-mode.ts | 28 +++++++++-- .../coding-agent/test/daemon-mode.test.ts | 49 ++++++++++++++++++- .../test/suite/agent-session-queue.test.ts | 27 ++++++++++ 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a22cee508f..4fd093c7df 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -361,6 +361,7 @@ export interface PromptOptions { interface QueuedFollowUpMessage { text: string; queueKey?: string; + message: AgentMessage; } /** Result from cycleModel() */ @@ -2336,17 +2337,18 @@ export class AgentSession { if (options.queueKey && this._followUpMessages.some((message) => message.queueKey === options.queueKey)) { return false; } - this._followUpMessages.push({ text, queueKey: options.queueKey }); - this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); } - this.agent.followUp({ + const message: AgentMessage = { role: "user", content, timestamp: Date.now(), - }); + }; + this._followUpMessages.push({ text, queueKey: options.queueKey, message }); + this._emitQueueUpdate(); + this.agent.followUp(message); return true; } @@ -2492,10 +2494,8 @@ export class AgentSession { return false; } this._followUpMessages = this._followUpMessages.filter((message) => message.queueKey !== queueKey); - const removedTexts = new Set(removed.map((message) => message.text)); - this.agent.removeQueuedMessages((message) => { - return message.role === "user" && removedTexts.has(this._getUserMessageText(message) ?? ""); - }); + const removedMessages = new Set(removed.map((message) => message.message)); + this.agent.removeQueuedMessages((message) => removedMessages.has(message)); this._emitQueueUpdate(); return true; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 5d37a556f2..ae52e4c0e6 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -477,9 +477,6 @@ export class AgentDaemon { const followUpQueueKey = isHeartbeatCronJob(job) ? `heartbeat:${job.id}` : undefined; if (followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { const didQueue = await state.runtime.session.followUp(job.prompt, undefined, { queueKey: followUpQueueKey }); - if (!didQueue) { - state.runtime.session.removeQueuedFollowUp(followUpQueueKey); - } return didQueue ? undefined : "skipped"; } if (!followUpQueueKey && (state.runtime.session.isStreaming || state.runtime.session.pendingMessageCount > 0)) { @@ -518,6 +515,7 @@ export class AgentDaemon { if (!sessionFile) { throw new Error("Heartbeats require a persisted session file"); } + const previousHeartbeat = this.cronStore.getHeartbeat(state.activeSessionId); const job = this.cronStore.createHeartbeat({ activeSessionId: state.activeSessionId, sessionId: session.sessionId, @@ -527,6 +525,9 @@ export class AgentDaemon { scheduleText: normalizeHeartbeatSchedule(schedule), prompt: instruction, }); + if (previousHeartbeat) { + this.removeQueuedHeartbeatFollowUp(state, previousHeartbeat); + } this.cronScheduler.wake(); return job; } @@ -541,6 +542,9 @@ export class AgentDaemon { : action === "resume" ? this.cronStore.resumeHeartbeat(state.activeSessionId) : this.cronStore.clearHeartbeat(state.activeSessionId); + if (job && action !== "resume") { + this.removeQueuedHeartbeatFollowUp(state, job); + } this.cronScheduler.wake(); return job; } @@ -579,6 +583,9 @@ export class AgentDaemon { status: input.status, }); if (job) { + if (input.instruction !== undefined || input.interval !== undefined || input.status === "pause") { + this.removeQueuedHeartbeatFollowUp(state, job); + } this.cronScheduler.wake(); } return job; @@ -587,6 +594,7 @@ export class AgentDaemon { private deleteRlmHeartbeatForState(state: ActiveSessionState, id: string): AgentCronJob | undefined { const job = this.cronStore.deleteRlmHeartbeat(state.activeSessionId, id); if (job) { + this.removeQueuedHeartbeatFollowUp(state, job); this.cronScheduler.wake(); } return job; @@ -613,11 +621,21 @@ export class AgentDaemon { return; } const cancelled = this.cronStore.cancelRlmHeartbeatsForSession(state.activeSessionId); + for (const job of cancelled) { + this.removeQueuedHeartbeatFollowUp(state, job); + } if (cancelled.length > 0) { this.cronScheduler.wake(); } } + private removeQueuedHeartbeatFollowUp(state: ActiveSessionState, job: AgentCronJob): void { + if (!isHeartbeatCronJob(job)) { + return; + } + state.runtime.session.removeQueuedFollowUp(`heartbeat:${job.id}`); + } + private async getOrCreateCronJobSession(job: AgentCronJob): Promise { const current = this.sessions.get(job.activeSessionId) ?? this.findSessionBySessionFile(job.sessionFile); if (current) { @@ -1144,6 +1162,10 @@ export class AgentDaemon { if (!job) { throw new Error(`No cron job found: ${command.jobId}`); } + const state = this.sessions.get(job.activeSessionId); + if (state) { + this.removeQueuedHeartbeatFollowUp(state, job); + } this.cronScheduler.wake(); return success(command.id, "cron_cancel", { job }); } diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 469859a079..28514dbe4b 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -306,7 +306,7 @@ describe("daemon mode helpers", () => { expect(result).toBe("skipped"); expect(followUp).toHaveBeenCalledWith("heartbeat prompt", undefined, { queueKey: "heartbeat:heartbeat-1" }); - expect(removeQueuedFollowUp).toHaveBeenCalledWith("heartbeat:heartbeat-1"); + expect(removeQueuedFollowUp).not.toHaveBeenCalled(); }); it("queues generic cron jobs behind pending messages", async () => { @@ -339,6 +339,53 @@ describe("daemon mode helpers", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("removes queued heartbeat follow-ups when a heartbeat is cleared", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-heartbeat-clear-")); + try { + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const removeQueuedFollowUp = vi.fn(() => true); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: ActiveSessionState["runtime"]["session"] & { + removeQueuedFollowUp: typeof removeQueuedFollowUp; + }; + }; + }; + state.runtime.session = { removeQueuedFollowUp } as never; + const internals = daemon as unknown as { + cronStore: AgentCronJobStore; + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + const heartbeat = internals.cronStore.createHeartbeat({ + activeSessionId: state.activeSessionId, + sessionId: "session-1", + sessionFile: join(tempDir, "session.jsonl"), + cwd: tempDir, + scheduleText: "every 5m", + prompt: "check on the session", + now: new Date("2026-01-01T12:00:00.000Z"), + }); + + await internals.handleCommand(makeClient("client-1", state.activeSessionId), { + id: "command-1", + type: "heartbeat_update", + activeSessionId: state.activeSessionId, + action: "clear", + }); + + expect(removeQueuedFollowUp).toHaveBeenCalledWith(`heartbeat:${heartbeat.id}`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("validates active sessions before reading a heartbeat", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 6e15981ae4..a3bf6ed4c9 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -173,6 +173,33 @@ describe("AgentSession queue characterization", () => { await promptPromise; }); + it("removes only the matching coalesced follow-up when texts match", async () => { + const waiting = await createWaitingHarness(); + const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting; + harnesses.push(harness); + + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + await waitForToolStart; + await harness.session.prompt("same heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:one", + }); + await harness.session.prompt("same heartbeat", { + streamingBehavior: "followUp", + followUpQueueKey: "heartbeat:two", + }); + + expect(harness.session.removeQueuedFollowUp("heartbeat:one")).toBe(true); + expect(harness.session.getFollowUpMessages()).toEqual(["same heartbeat"]); + + releaseToolExecution(); + await promptPromise; + expect(getUserTexts(harness)).toEqual(["start", "same heartbeat"]); + }); + it("removes coalesced follow-up messages by queue key", async () => { const waiting = await createWaitingHarness(); const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;