mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 14:12:57 -06:00
feat(cron): database-backed heartbeat monitor scratch replaces HEARTBEAT.md (#112967)
* feat(cron): move heartbeat context into database-backed per-job cron scratch - new additive cron_job_scratch SQLite table (revision tombstones keep compare-and-swap monotonic across unset/recreate; 256KiB cap) - heartbeat runner reads monitor scratch instead of workspace HEARTBEAT.md; heartbeat_respond gains a private scratch replacement parameter - openclaw doctor --fix migrates HEARTBEAT.md into scratch (hash-verified, archived under state backups, idempotent, symlink-contained) - gateway cron.scratch.get/set admin RPCs + openclaw cron scratch CLI - workspace bootstrap no longer seeds HEARTBEAT.md; Codex heartbeat file guidance removed; docs and prompt snapshots updated * fix(cron): review round 2 — shared-workspace heartbeat migration and non-default agent monitors - doctor migration groups agents by heartbeat source file and imports into every monitor before archiving/removing the shared file once - exempt heartbeat payloads from the main-session default-agent restriction: monitors only poke the wake bus, so non-default agents converge again - document why disabled monitors retain their last cadence (config default already resolves before the fallback) * fix(cron): honor configured cron store, legacy heartbeat fallback, and safer doctor claim * fix(cron): claim HEARTBEAT.md before committing scratch and restore without clobbering * fix(cron): pin migration CAS to precondition revision, re-verify claim on release, archive first, report scratch as pending * docs(heartbeat): remove retired config options * fix(cron): crash-recoverable migration claims, partial-import rollback, latest-response scratch pairing * test(heartbeat): keep latest scratch proposal paired * fix(cron): roll back committed scratch on changed-claim release and restore no-row state * fix(cron): revision-guarded rollback delete and recreated-file detection on claim release * fix(cron): treat every failed claim re-verification as a migration conflict * test(heartbeat): rename ack test after ackMaxChars retirement * fix(heartbeat): keep monitor scratch out of bypass-scope runs * fix(cron): resolve claimed symlinks on release and gate legacy fallback on proven scratch state * fix(cron): strict claim-name recovery and per-entry migration grouping * fix(ci): heartbeat scratch gate repairs — lint causes, dead exports, since-train, inventory path, prompt snapshot, regenerated docs map, SDK baseline, protocol bindings * fix(cron): live-owner claim guard and canonical entry-key migration grouping * fix(cron): archive claimed inode on release and flag orphan claims beside recreated files * docs(cron): document process-global state-db invariant for scratch service ops * chore(i18n): refresh native inventory line numbers after protocol binding regen
This commit is contained in:
committed by
GitHub
parent
78b987aa2e
commit
3e2b3ea4d5
@@ -26483,7 +26483,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 122,
|
||||
"line": 123,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Hide Sidebar",
|
||||
"surface": "apple",
|
||||
@@ -26491,7 +26491,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 209,
|
||||
"line": 210,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Selected",
|
||||
"surface": "apple",
|
||||
@@ -26499,7 +26499,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 233,
|
||||
"line": 234,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "More Agents",
|
||||
"surface": "apple",
|
||||
@@ -26507,7 +26507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 336,
|
||||
"line": 337,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Search sessions",
|
||||
"surface": "apple",
|
||||
@@ -26515,7 +26515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 347,
|
||||
"line": 348,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Clear session search",
|
||||
"surface": "apple",
|
||||
@@ -26523,7 +26523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 375,
|
||||
"line": 376,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Loading sessions",
|
||||
"surface": "apple",
|
||||
@@ -26531,7 +26531,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 383,
|
||||
"line": 384,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "No recent sessions",
|
||||
"surface": "apple",
|
||||
@@ -26539,7 +26539,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 393,
|
||||
"line": 394,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Recent",
|
||||
"surface": "apple",
|
||||
@@ -26547,7 +26547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 409,
|
||||
"line": 410,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "All Sessions…",
|
||||
"surface": "apple",
|
||||
@@ -26555,7 +26555,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 439,
|
||||
"line": 440,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Edit Pages",
|
||||
"surface": "apple",
|
||||
@@ -26563,7 +26563,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 463,
|
||||
"line": 464,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Home",
|
||||
"surface": "apple",
|
||||
@@ -26571,7 +26571,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 675,
|
||||
"line": 677,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Unread",
|
||||
"surface": "apple",
|
||||
@@ -26579,7 +26579,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 701,
|
||||
"line": 703,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Attention",
|
||||
"surface": "apple",
|
||||
@@ -26587,7 +26587,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 741,
|
||||
"line": 744,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "New Chat",
|
||||
"surface": "apple",
|
||||
@@ -26595,7 +26595,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 770,
|
||||
"line": 773,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Connection",
|
||||
"surface": "apple",
|
||||
@@ -26603,7 +26603,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 775,
|
||||
"line": 778,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Online",
|
||||
"surface": "apple",
|
||||
@@ -26611,7 +26611,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 776,
|
||||
"line": 779,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Connecting",
|
||||
"surface": "apple",
|
||||
@@ -26619,7 +26619,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 777,
|
||||
"line": 780,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Needs attention",
|
||||
"surface": "apple",
|
||||
@@ -26627,7 +26627,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 778,
|
||||
"line": 781,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
@@ -26635,7 +26635,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 877,
|
||||
"line": 880,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Pinned pages stay in the sidebar. Home is always shown.",
|
||||
"surface": "apple",
|
||||
@@ -26643,7 +26643,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 881,
|
||||
"line": 884,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Pages",
|
||||
"surface": "apple",
|
||||
@@ -26651,7 +26651,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 888,
|
||||
"line": 891,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Done",
|
||||
"surface": "apple",
|
||||
@@ -26659,7 +26659,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 927,
|
||||
"line": 930,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Pinned",
|
||||
"surface": "apple",
|
||||
@@ -26667,7 +26667,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 928,
|
||||
"line": 931,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Not pinned",
|
||||
"surface": "apple",
|
||||
@@ -26675,7 +26675,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 294,
|
||||
"line": 295,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Overview",
|
||||
"surface": "apple",
|
||||
@@ -26683,7 +26683,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 322,
|
||||
"line": 323,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Agents",
|
||||
"surface": "apple",
|
||||
@@ -26691,7 +26691,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 329,
|
||||
"line": 330,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Instances",
|
||||
"surface": "apple",
|
||||
@@ -26699,7 +26699,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 340,
|
||||
"line": 341,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Files",
|
||||
"surface": "apple",
|
||||
@@ -26707,7 +26707,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 347,
|
||||
"line": 348,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Dreaming",
|
||||
"surface": "apple",
|
||||
@@ -26715,7 +26715,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 354,
|
||||
"line": 355,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Usage",
|
||||
"surface": "apple",
|
||||
@@ -26723,7 +26723,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 361,
|
||||
"line": 362,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Automations",
|
||||
"surface": "apple",
|
||||
@@ -26731,7 +26731,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 451,
|
||||
"line": 452,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Hide Sidebar",
|
||||
"surface": "apple",
|
||||
@@ -26739,7 +26739,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 457,
|
||||
"line": 458,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Show Sidebar",
|
||||
"surface": "apple",
|
||||
@@ -26747,7 +26747,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 584,
|
||||
"line": 585,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Close canvas",
|
||||
"surface": "apple",
|
||||
@@ -26755,7 +26755,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 847,
|
||||
"line": 848,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Gateway needs attention",
|
||||
"surface": "apple",
|
||||
@@ -26763,7 +26763,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 847,
|
||||
"line": 848,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "OpenClaw iOS",
|
||||
"surface": "apple",
|
||||
@@ -26771,7 +26771,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 883,
|
||||
"line": 884,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Available",
|
||||
"surface": "apple",
|
||||
@@ -26779,7 +26779,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 883,
|
||||
"line": 884,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Gateway default",
|
||||
"surface": "apple",
|
||||
@@ -26787,7 +26787,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 883,
|
||||
"line": 884,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Routed on this phone",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -386,6 +386,8 @@ enum class GatewayMethod(
|
||||
CronGet("cron.get"),
|
||||
CronList("cron.list"),
|
||||
CronStatus("cron.status"),
|
||||
CronScratchGet("cron.scratch.get"),
|
||||
CronScratchSet("cron.scratch.set"),
|
||||
CronAdd("cron.add"),
|
||||
CronUpdate("cron.update"),
|
||||
CronRemove("cron.remove"),
|
||||
|
||||
@@ -13294,6 +13294,28 @@ public struct CronRunsParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronScratchGetResult: Codable, Sendable {
|
||||
public let scratch: AnyCodable
|
||||
public let currentrevision: Int
|
||||
public let maxbytes: Int
|
||||
|
||||
public init(
|
||||
scratch: AnyCodable,
|
||||
currentrevision: Int,
|
||||
maxbytes: Int)
|
||||
{
|
||||
self.scratch = scratch
|
||||
self.currentrevision = currentrevision
|
||||
self.maxbytes = maxbytes
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case scratch
|
||||
case currentrevision = "currentRevision"
|
||||
case maxbytes = "maxBytes"
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
|
||||
@@ -103,7 +103,7 @@ aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/questio
|
||||
bd2355e94248d21c252148085e5afa5db9a2f3f48f665a8b3e0fcf77326d9b6c module/reply-dispatch-runtime
|
||||
ac2b199e95c5c8b1e2a65e62bd41d1b6322e531bca294ef4979a297a12640bce module/reply-history
|
||||
f394fe4d5a7ed9e4d574063ae44e8d6af85c9a0e7d8b329f750ca16b0664325f module/reply-payload
|
||||
4e886f327f912688c338a1eccb208298987613f81325d4f999852b6925487eb2 module/reply-runtime
|
||||
554bbc681b17bf102a5e240e3f563c7d3c3f1dbab31f8fa3c9285f106c3e8111 module/reply-runtime
|
||||
d78db621b8f4f0cc679cad2d5d21b6c95b5418c58611dd347cdf09049ed124f6 module/routing
|
||||
ff6cca86f54f94f238205f5b122af36666314e0a380f3ec7f0ccb9ed9208df31 module/run-command
|
||||
53b0295cec105696a1664c5c7f5576a7b55d197eb95dcd9185486f010bd53750 module/runtime
|
||||
|
||||
@@ -280,7 +280,7 @@ Throws, timeouts, exhausted tool budgets, invalid results, and `nextCheck` witho
|
||||
<Accordion title="Main session vs isolated vs custom">
|
||||
**Main session** jobs enqueue a system event into a cron-owned run lane and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). They can use the target main session's last delivery context for replies, but do not append routine cron turns to the human chat lane and do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
|
||||
|
||||
Main-session cron events are self-contained system-event reminders. They do not automatically include the default heartbeat prompt's "Read HEARTBEAT.md" instruction; say that explicitly in the cron event text if a reminder should consult `HEARTBEAT.md`.
|
||||
Main-session cron events are self-contained system-event reminders. They do not automatically include the default heartbeat prompt or the heartbeat monitor scratch; say it explicitly in the cron event text if a reminder should consult that context.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="What 'fresh session' means for isolated jobs">
|
||||
|
||||
@@ -306,6 +306,18 @@ openclaw cron runs --id <job-id> --run-id <run-id>
|
||||
|
||||
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
|
||||
|
||||
Private per-job scratch (heartbeat checklists and similar monitor context):
|
||||
|
||||
```bash
|
||||
openclaw cron scratch <job-id> # print current scratch content
|
||||
openclaw cron scratch <job-id> --json # scratch plus revision metadata
|
||||
openclaw cron scratch <job-id> --set "text" # replace scratch with exact text
|
||||
openclaw cron scratch <job-id> --file notes.md # replace scratch from a file (- for stdin)
|
||||
openclaw cron scratch <job-id> --unset # remove the scratch row
|
||||
```
|
||||
|
||||
Scratch is stored in the shared state database, capped at 256 KiB, and never included in `cron list`/`cron get`/`cron runs` output. Writes are compare-and-swap guarded against the revision read at command start; pass `--expected-revision <n>` to pin an explicit revision instead. See [Heartbeat](/gateway/heartbeat#monitor-scratch-optional) for how heartbeat monitors use scratch.
|
||||
|
||||
Agent and session retargeting:
|
||||
|
||||
```bash
|
||||
|
||||
+2
-3
@@ -3600,11 +3600,10 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H3: What each flag does
|
||||
- H3: Per-channel vs per-account examples
|
||||
- H3: Common patterns
|
||||
- H2: HEARTBEAT.md (optional)
|
||||
- H2: Monitor scratch (optional)
|
||||
- H3: tasks: blocks
|
||||
- H3: Can the agent update HEARTBEAT.md?
|
||||
- H3: Can the agent update its scratch?
|
||||
- H2: Manual wake (on-demand)
|
||||
- H2: Reasoning delivery (optional)
|
||||
- H2: Cost awareness
|
||||
- H2: Context overflow after heartbeat
|
||||
- H2: Related
|
||||
|
||||
@@ -63,7 +63,7 @@ Optional default skill allowlist for agents that do not set
|
||||
|
||||
### `agents.defaults.skipBootstrap`
|
||||
|
||||
Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`).
|
||||
Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `BOOTSTRAP.md`).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -73,7 +73,7 @@ Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`
|
||||
|
||||
### `agents.defaults.skipOptionalBootstrapFiles`
|
||||
|
||||
Skips creation of selected optional workspace files while still writing required bootstrap files (`AGENTS.md`, `TOOLS.md`, `BOOTSTRAP.md`). Valid values: `SOUL.md`, `USER.md`, `HEARTBEAT.md`, and `IDENTITY.md`.
|
||||
Skips creation of selected optional workspace files while still writing required bootstrap files (`AGENTS.md`, `TOOLS.md`, `BOOTSTRAP.md`). Valid values: `SOUL.md`, `USER.md`, and `IDENTITY.md` (`HEARTBEAT.md` is accepted but a no-op since heartbeat context moved to cron monitor scratch).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -553,14 +553,14 @@ Periodic heartbeat runs.
|
||||
model: "openai/gpt-5.4-mini",
|
||||
includeReasoning: false,
|
||||
includeSystemPromptSection: true, // default: true; false omits the Heartbeat section from the system prompt
|
||||
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
|
||||
lightContext: false, // default: false; true skips workspace bootstrap files for heartbeat runs
|
||||
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
|
||||
skipWhenBusy: false, // default: false; true also waits for this agent's subagent/nested lanes
|
||||
session: "main",
|
||||
to: "+15555550123",
|
||||
directPolicy: "allow", // allow (default) | block
|
||||
target: "none", // default: none | options: last | whatsapp | telegram | discord | ...
|
||||
prompt: "Read HEARTBEAT.md if it exists...",
|
||||
prompt: "Follow the heartbeat monitor scratch context...",
|
||||
ackMaxChars: 300,
|
||||
suppressToolErrorWarnings: false,
|
||||
timeoutSeconds: 45,
|
||||
@@ -571,11 +571,11 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable.
|
||||
- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt and skips `HEARTBEAT.md` injection into bootstrap context. Default: `true`.
|
||||
- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt. Default: `true`.
|
||||
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
|
||||
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
|
||||
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
|
||||
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and skip workspace bootstrap files. Monitor scratch is injected by the heartbeat runner either way.
|
||||
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
|
||||
- `skipWhenBusy`: when true, heartbeat runs defer on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeats, even without this flag.
|
||||
- Per-agent: set `agents.entries.*.heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
|
||||
|
||||
+40
-70
@@ -25,15 +25,14 @@ Troubleshooting: [Scheduled Tasks](/automation/cron-jobs#troubleshooting)
|
||||
<Step title="Pick a cadence">
|
||||
Leave heartbeats enabled (default is `30m`, or `1h` when Anthropic OAuth/token auth is configured, including Claude CLI reuse) or set your own cadence.
|
||||
</Step>
|
||||
<Step title="Add HEARTBEAT.md (optional)">
|
||||
Create a tiny `HEARTBEAT.md` checklist or `tasks:` block in the agent workspace.
|
||||
<Step title="Add monitor scratch (optional)">
|
||||
Store a tiny checklist or `tasks:` block in the heartbeat monitor's scratch with `openclaw cron scratch <jobId> --set "..."`.
|
||||
</Step>
|
||||
<Step title="Decide where heartbeat messages should go">
|
||||
`target: "none"` is the default; set `target: "last"` to route to the last contact.
|
||||
</Step>
|
||||
<Step title="Optional tuning">
|
||||
- Enable heartbeat reasoning delivery for transparency.
|
||||
- Use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
|
||||
- Use lightweight bootstrap context if heartbeat runs only need the monitor scratch.
|
||||
- Enable isolated sessions to avoid sending full conversation history each heartbeat.
|
||||
- Restrict heartbeats to active hours (local time).
|
||||
|
||||
@@ -50,11 +49,9 @@ Example config:
|
||||
every: "30m",
|
||||
target: "last", // explicit delivery to last contact (default is "none")
|
||||
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
|
||||
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
|
||||
lightContext: true, // optional: skip workspace bootstrap files for heartbeat runs
|
||||
isolatedSession: true, // optional: fresh session each run (no conversation history)
|
||||
skipWhenBusy: true, // optional: also defer when this agent's subagent or nested lanes are busy
|
||||
// activeHours: { start: "08:00", end: "24:00" },
|
||||
// includeReasoning: true, // optional: send separate `Thinking` message too
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -64,12 +61,12 @@ Example config:
|
||||
## Defaults
|
||||
|
||||
- Interval: `30m`. Applying Anthropic provider defaults bumps this to `1h` when the resolved auth mode is OAuth/token (including Claude CLI reuse), but only while `heartbeat.every` is unset. Set `agents.defaults.heartbeat.every` or per-agent `agents.entries.*.heartbeat.every`; use `0m` to disable.
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.entries.*.heartbeat.timeoutSeconds` for longer heartbeat work.
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeats" section only when heartbeats are enabled for the default agent (and `includeSystemPromptSection` is not `false`), and the run is flagged internally.
|
||||
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeats" section when heartbeats are enabled for the default agent, and the run is flagged internally.
|
||||
- When heartbeats are disabled with `0m`, the monitor cron job stays but is disabled, and its scratch is retained for when you re-enable the cadence.
|
||||
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
|
||||
- Heartbeats automatically defer while cron work is active or queued. Set `heartbeat.skipWhenBusy: true` to also defer an agent on its own session-keyed subagent or nested command lanes; sibling agents no longer pause just because another agent has subagent work in flight.
|
||||
- Heartbeats automatically defer while cron work is active or queued, or while that agent's session-keyed subagent or nested command lanes are busy. Sibling agents do not pause each other.
|
||||
|
||||
## What the heartbeat prompt is for
|
||||
|
||||
@@ -87,7 +84,7 @@ If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub
|
||||
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
|
||||
- Heartbeat runs may instead call `heartbeat_respond` with `notify: false` for no visible update, or `notify: true` plus `notificationText` for an alert. When present, the structured tool response takes precedence over the text fallback.
|
||||
- A meaningful `heartbeat_respond` result with `notify: false` remains silent but is remembered as bounded internal context for the next user turn in that session. `no_change` acknowledgments and visible notifications are not stored this way.
|
||||
- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is **≤ `ackMaxChars`** (default: 300).
|
||||
- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is at most 300 characters.
|
||||
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated specially.
|
||||
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.
|
||||
|
||||
@@ -102,16 +99,12 @@ Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripp
|
||||
heartbeat: {
|
||||
every: "30m", // default: 30m (0m disables)
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
includeReasoning: false, // default: false (deliver separate Thinking message when available)
|
||||
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
|
||||
lightContext: false, // default: false; true skips workspace bootstrap files for heartbeat runs
|
||||
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
|
||||
skipWhenBusy: false, // default: false; true also waits for this agent's subagent/nested lanes
|
||||
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "imessage")
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
accountId: "ops-bot", // optional multi-account channel id
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
includeSystemPromptSection: true, // default: true; false omits the ## Heartbeats system prompt section for the default agent
|
||||
ackMaxChars: 300, // max chars allowed after HEARTBEAT_OK
|
||||
prompt: "Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -150,7 +143,7 @@ Example: two agents, only the second agent runs heartbeats.
|
||||
target: "whatsapp",
|
||||
to: "+15551234567",
|
||||
timeoutSeconds: 45,
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
prompt: "Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -230,18 +223,12 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
<ParamField path="model" type="string">
|
||||
Optional model override for heartbeat runs (`provider/model`).
|
||||
</ParamField>
|
||||
<ParamField path="includeReasoning" type="boolean" default="false">
|
||||
When enabled, also deliver the separate `Thinking` message when available (same shape as `/reasoning on`).
|
||||
</ParamField>
|
||||
<ParamField path="lightContext" type="boolean" default="false">
|
||||
When true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
When true, heartbeat runs use lightweight bootstrap context and skip workspace bootstrap files. Monitor scratch is injected by the heartbeat runner either way.
|
||||
</ParamField>
|
||||
<ParamField path="isolatedSession" type="boolean" default="false">
|
||||
When true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
|
||||
</ParamField>
|
||||
<ParamField path="skipWhenBusy" type="boolean" default="false">
|
||||
When true, heartbeat runs defer on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeats, even without this flag, so local-model hosts do not run cron and heartbeat prompts at the same time.
|
||||
</ParamField>
|
||||
<ParamField path="session" type="string">
|
||||
Optional session key for heartbeat runs.
|
||||
|
||||
@@ -271,18 +258,6 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
<ParamField path="prompt" type="string">
|
||||
Overrides the default prompt body (not merged).
|
||||
|
||||
</ParamField>
|
||||
<ParamField path="includeSystemPromptSection" type="boolean" default="true">
|
||||
Whether the default agent's `## Heartbeats` system prompt section is injected. Set `false` to keep heartbeat runtime behavior (cadence, delivery, HEARTBEAT.md) while omitting the heartbeat instructions from the agent system prompt.
|
||||
|
||||
</ParamField>
|
||||
<ParamField path="ackMaxChars" type="number" default="300">
|
||||
Max chars allowed after `HEARTBEAT_OK` before delivery.
|
||||
|
||||
</ParamField>
|
||||
<ParamField path="suppressToolErrorWarnings" type="boolean">
|
||||
When true, suppresses tool error warning payloads during heartbeat runs.
|
||||
|
||||
</ParamField>
|
||||
<ParamField path="timeoutSeconds" type="number" default="global timeout or min(every, 600)">
|
||||
Maximum seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
|
||||
@@ -308,7 +283,6 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
- To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session.
|
||||
- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
|
||||
- If the main queue, target session lane, cron lane, or an active cron job is busy, the heartbeat is skipped and retried later.
|
||||
- If `skipWhenBusy: true`, this agent's session-keyed subagent and nested lanes also defer heartbeat runs. Other agents' busy lanes do not defer this agent.
|
||||
- If `target` resolves to no external destination, the run still happens but no outbound message is sent.
|
||||
|
||||
</Accordion>
|
||||
@@ -387,19 +361,32 @@ channels:
|
||||
| Indicator-only (no messages) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: true }` |
|
||||
| OKs in one channel only | `channels.telegram.heartbeat: { showOk: true }` |
|
||||
|
||||
## HEARTBEAT.md (optional)
|
||||
## Monitor scratch (optional)
|
||||
|
||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your "heartbeat checklist": small, stable, and safe to consider every 30 minutes.
|
||||
Each heartbeat monitor cron job owns a private scratch document stored in the shared state database. Think of it as your "heartbeat checklist": small, stable, and safe to consider every 30 minutes. When scratch exists, its content is appended to the heartbeat prompt.
|
||||
|
||||
On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabled for the default agent. Disabling the heartbeat cadence with `0m` or setting `includeSystemPromptSection: false` omits it from normal bootstrap context.
|
||||
Manage it with the cron CLI (the job id comes from `openclaw cron list --all`):
|
||||
|
||||
On the native Codex harness, `HEARTBEAT.md` content is not injected into the turn like other bootstrap files. If the file exists and has non-whitespace content, a heartbeat collaboration-mode note points Codex at the file and tells it to read the file before proceeding.
|
||||
```bash
|
||||
openclaw cron scratch <jobId> # print the current scratch
|
||||
openclaw cron scratch <jobId> --set "..." # replace it with exact text
|
||||
openclaw cron scratch <jobId> --file notes.md # replace it from a file (- for stdin)
|
||||
openclaw cron scratch <jobId> --unset # remove it
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
Writes are compare-and-swap guarded: pass `--expected-revision <n>` to fail instead of overwriting a concurrent edit. Scratch is capped at 256 KiB and never appears in `cron list`/`cron runs` output.
|
||||
|
||||
The agent can also update its own scratch: during a heartbeat turn, `heartbeat_respond` accepts an optional `scratch` string that fully replaces the monitor's scratch for future heartbeats.
|
||||
|
||||
<Note>
|
||||
**Migrating from HEARTBEAT.md?** Run `openclaw doctor --fix`. Doctor imports each agent's workspace `HEARTBEAT.md` into the monitor's scratch, archives the original under the state directory (`backups/heartbeat-migration/`), and then removes the file. For one stable upgrade window, an unmigrated legacy file remains a read-only fallback when no scratch revision exists, with a Gateway warning directing you to Doctor; new workspaces and completed migrations use database scratch only.
|
||||
</Note>
|
||||
|
||||
If scratch exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If no scratch exists, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||
|
||||
Example `HEARTBEAT.md`:
|
||||
Example scratch:
|
||||
|
||||
```md
|
||||
# Heartbeat checklist
|
||||
@@ -411,7 +398,7 @@ Example `HEARTBEAT.md`:
|
||||
|
||||
### `tasks:` blocks
|
||||
|
||||
`HEARTBEAT.md` also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
|
||||
Scratch also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -436,28 +423,21 @@ tasks:
|
||||
- OpenClaw parses the `tasks:` block and checks each task against its own `interval`.
|
||||
- Only **due** tasks are included in the heartbeat prompt for that tick.
|
||||
- If no tasks are due, the heartbeat is skipped entirely (`reason=no-tasks-due`) to avoid a wasted model call.
|
||||
- Non-task content in `HEARTBEAT.md` is preserved and appended as additional context after the due-task list.
|
||||
- Non-task scratch content is preserved and appended as additional context after the due-task list.
|
||||
- Task last-run timestamps are stored in session state (`heartbeatTaskState`), so intervals survive normal restarts.
|
||||
- Task timestamps are only advanced after a heartbeat run completes its normal reply path. Skipped `empty-heartbeat-file` / `no-tasks-due` runs do not mark tasks as completed.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Task mode is useful when you want one heartbeat file to hold several periodic checks without paying for all of them every tick.
|
||||
Task mode is useful when you want one scratch document to hold several periodic checks without paying for all of them every tick.
|
||||
|
||||
### Can the agent update HEARTBEAT.md?
|
||||
### Can the agent update its scratch?
|
||||
|
||||
Yes - if you ask it to.
|
||||
|
||||
`HEARTBEAT.md` is just a normal file in the agent workspace, so you can tell the agent (in a normal chat) something like:
|
||||
|
||||
- "Update `HEARTBEAT.md` to add a daily calendar check."
|
||||
- "Rewrite `HEARTBEAT.md` so it's shorter and focused on inbox follow-ups."
|
||||
|
||||
If you want this to happen proactively, you can also include an explicit line in your heartbeat prompt like: "If the checklist becomes stale, update HEARTBEAT.md with a better one."
|
||||
Yes. During a heartbeat turn, the agent can pass a `scratch` value to `heartbeat_respond` to fully replace the monitor scratch for future heartbeats. You can also ask it in a normal chat to run `openclaw cron scratch <jobId> --set ...`, or edit the scratch yourself with the same command.
|
||||
|
||||
<Warning>
|
||||
Don't put secrets (API keys, phone numbers, private tokens) into `HEARTBEAT.md` - it becomes part of the prompt context.
|
||||
Don't put secrets (API keys, phone numbers, private tokens) into monitor scratch - it becomes part of the prompt context.
|
||||
</Warning>
|
||||
|
||||
## Manual wake (on-demand)
|
||||
@@ -485,24 +465,14 @@ openclaw system heartbeat enable # enable heartbeats
|
||||
openclaw system heartbeat disable # disable heartbeats
|
||||
```
|
||||
|
||||
## Reasoning delivery (optional)
|
||||
|
||||
By default, heartbeats deliver only the final "answer" payload.
|
||||
|
||||
If you want transparency, enable:
|
||||
|
||||
- `agents.defaults.heartbeat.includeReasoning: true`
|
||||
|
||||
When enabled, heartbeats will also deliver a separate message prefixed `Thinking` (same shape as `/reasoning on`). This can be useful when the agent is managing multiple sessions/codexes and you want to see why it decided to ping you - but it can also leak more internal detail than you want. Prefer keeping it off in group chats.
|
||||
|
||||
## Cost awareness
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
|
||||
|
||||
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
|
||||
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
|
||||
- Use `lightContext: true` to skip workspace bootstrap files for heartbeat runs.
|
||||
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
|
||||
- Keep `HEARTBEAT.md` small.
|
||||
- Keep the monitor scratch small.
|
||||
- Use `target: "none"` if you only want internal state updates.
|
||||
|
||||
## Context overflow after heartbeat
|
||||
|
||||
@@ -64,7 +64,7 @@ When onboarding finishes, OpenClaw auto-opens the dashboard and prints a clean (
|
||||
|
||||
OpenClaw reads operating instructions and "memory" from its workspace directory.
|
||||
|
||||
By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and creates it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`) automatically on onboarding or first agent run. `BOOTSTRAP.md` is only created for a brand-new workspace and should not come back after you delete it. `MEMORY.md` is optional and never auto-created; when present, it loads for normal sessions. Subagent sessions only inject `AGENTS.md` and `TOOLS.md`.
|
||||
By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and creates it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on onboarding or first agent run. `BOOTSTRAP.md` is only created for a brand-new workspace and should not come back after you delete it. `MEMORY.md` is optional and never auto-created; when present, it loads for normal sessions. Subagent sessions only inject `AGENTS.md` and `TOOLS.md`.
|
||||
|
||||
<Tip>
|
||||
Treat this folder like OpenClaw's memory and make it a git repo (ideally private) so your `AGENTS.md` and memory files are backed up. If git is installed, brand-new workspaces are auto-initialized with `git init`.
|
||||
@@ -168,11 +168,11 @@ Example:
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
By default, OpenClaw runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
`Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable. Heartbeat checklists live in the monitor's cron scratch (see [Heartbeat](/gateway/heartbeat)); `openclaw doctor --fix` migrates a legacy workspace `HEARTBEAT.md` into it.
|
||||
|
||||
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls.
|
||||
- If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
- If the monitor scratch exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls.
|
||||
- If no scratch exists, the heartbeat still runs and the model decides what to do.
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat.
|
||||
- By default, heartbeat delivery to DM-style `user:<id>` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.
|
||||
- Heartbeats run full agent turns - shorter intervals burn more tokens.
|
||||
|
||||
@@ -122,7 +122,7 @@ Malformed local-model reasoning tags are handled conservatively. Closed `<think>
|
||||
|
||||
## Heartbeats
|
||||
|
||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Thinking` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.entries.*.heartbeat.includeReasoning: true`.
|
||||
|
||||
## Web chat UI
|
||||
|
||||
@@ -93,7 +93,6 @@ describe("Codex app-server attempt context", () => {
|
||||
contextFiles: [],
|
||||
promptContextFiles: [],
|
||||
developerInstructionFiles: [],
|
||||
heartbeatReferenceFiles: [],
|
||||
},
|
||||
skillsPrompt: "",
|
||||
tools,
|
||||
|
||||
@@ -43,7 +43,6 @@ const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
...CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
|
||||
...CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
|
||||
]);
|
||||
const CODEX_HEARTBEAT_CONTEXT_BASENAME = "heartbeat.md";
|
||||
const CODEX_MEMORY_CONTEXT_BASENAME = "memory.md";
|
||||
const CODEX_MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
|
||||
const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
|
||||
@@ -53,7 +52,6 @@ const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
|
||||
["tools.md", 40],
|
||||
["bootstrap.md", 50],
|
||||
["memory.md", 60],
|
||||
["heartbeat.md", 70],
|
||||
]);
|
||||
|
||||
type CodexBootstrapFile = Awaited<ReturnType<typeof resolveBootstrapFilesForRun>>[number];
|
||||
@@ -68,7 +66,6 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
|
||||
promptContextFiles?: EmbeddedContextFile[];
|
||||
developerInstructionFiles?: EmbeddedContextFile[];
|
||||
turnScopedDeveloperInstructionFiles?: EmbeddedContextFile[];
|
||||
heartbeatReferenceFiles?: EmbeddedContextFile[];
|
||||
memoryReferenceFiles?: EmbeddedContextFile[];
|
||||
memoryToolRoutedBootstrapFiles?: CodexBootstrapFile[];
|
||||
memoryToolNames?: string[];
|
||||
@@ -77,7 +74,6 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
|
||||
developerInstructions?: string;
|
||||
turnScopedDeveloperInstructions?: string;
|
||||
memoryCollaborationInstructions?: string;
|
||||
heartbeatCollaborationInstructions?: string;
|
||||
};
|
||||
|
||||
/** Reads mirrored Codex session history for harness hooks. */
|
||||
@@ -242,14 +238,12 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
|
||||
)
|
||||
? selectCodexWorkspaceTurnScopedDeveloperInstructionFiles(contextFiles)
|
||||
: [];
|
||||
const heartbeatReferenceFiles = selectCodexWorkspaceHeartbeatReferenceFiles(contextFiles);
|
||||
return {
|
||||
bootstrapFiles,
|
||||
contextFiles,
|
||||
promptContextFiles,
|
||||
developerInstructionFiles,
|
||||
turnScopedDeveloperInstructionFiles,
|
||||
heartbeatReferenceFiles,
|
||||
memoryReferenceFiles,
|
||||
memoryToolRoutedBootstrapFiles,
|
||||
memoryToolNames: [...params.memoryToolNames],
|
||||
@@ -271,8 +265,6 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
|
||||
sandboxed: params.sandboxed,
|
||||
})
|
||||
: undefined,
|
||||
heartbeatCollaborationInstructions:
|
||||
renderCodexWorkspaceHeartbeatReference(heartbeatReferenceFiles),
|
||||
};
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("failed to load codex workspace bootstrap instructions", { error });
|
||||
@@ -452,14 +444,9 @@ function buildCodexBootstrapInjectionStats(params: {
|
||||
readCodexIndexedContextFileContent(developerInstructionIndex, pathValue, fileName));
|
||||
let injectedChars = memoryToolRoutedFile ? 0 : (injected?.length ?? 0);
|
||||
let truncated = memoryToolRoutedFile ? false : !file.missing && injectedChars < rawChars;
|
||||
if (injected === undefined) {
|
||||
if (CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName)) {
|
||||
injectedChars = rawChars;
|
||||
truncated = false;
|
||||
} else if (baseName === CODEX_HEARTBEAT_CONTEXT_BASENAME) {
|
||||
injectedChars = 0;
|
||||
truncated = false;
|
||||
}
|
||||
if (injected === undefined && CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName)) {
|
||||
injectedChars = rawChars;
|
||||
truncated = false;
|
||||
}
|
||||
return {
|
||||
name: displayName,
|
||||
@@ -669,7 +656,7 @@ function renderCodexWorkspaceBootstrapPromptContext(
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. Those files are not repeated here.",
|
||||
"",
|
||||
"# Project Context",
|
||||
"",
|
||||
@@ -694,7 +681,6 @@ function selectCodexWorkspacePromptContextFiles(
|
||||
baseName &&
|
||||
!CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName) &&
|
||||
!CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES.has(baseName) &&
|
||||
baseName !== CODEX_HEARTBEAT_CONTEXT_BASENAME &&
|
||||
(!excludeMemory ||
|
||||
!isCodexWorkspaceRootMemoryContextFile({
|
||||
file,
|
||||
@@ -787,37 +773,6 @@ function renderCodexWorkspaceDeveloperInstructions(params: {
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function selectCodexWorkspaceHeartbeatReferenceFiles(
|
||||
contextFiles: EmbeddedContextFile[],
|
||||
): EmbeddedContextFile[] {
|
||||
return contextFiles
|
||||
.filter((file) => {
|
||||
const baseName = getCodexContextFileBasename(file.path);
|
||||
return (
|
||||
baseName === CODEX_HEARTBEAT_CONTEXT_BASENAME &&
|
||||
!isMissingCodexBootstrapContextFile(file) &&
|
||||
file.content.trim().length > 0
|
||||
);
|
||||
})
|
||||
.toSorted(compareCodexContextFiles);
|
||||
}
|
||||
|
||||
function renderCodexWorkspaceHeartbeatReference(files: EmbeddedContextFile[]): string | undefined {
|
||||
if (files.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
"## OpenClaw Heartbeat Workspace",
|
||||
"",
|
||||
"HEARTBEAT.md exists in the active agent workspace. Read it before proceeding with this heartbeat, then decide what action is appropriate.",
|
||||
"",
|
||||
];
|
||||
for (const file of files) {
|
||||
lines.push(`- ${file.path}`);
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function selectCodexWorkspaceMemoryReferenceFiles(params: {
|
||||
bootstrapFiles: CodexBootstrapFile[];
|
||||
workspaceDir: string;
|
||||
|
||||
@@ -290,8 +290,6 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) {
|
||||
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
skillsCollaborationInstructions,
|
||||
memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions,
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
}).settings.developer_instructions ?? undefined,
|
||||
);
|
||||
const rebuildCodexPromptBuildFromCurrentProjection = async () => {
|
||||
|
||||
@@ -112,8 +112,6 @@ export async function prepareCodexAttemptTurnRequest(
|
||||
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
skillsCollaborationInstructions: context.skillsCollaborationInstructions,
|
||||
memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions,
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
preserveNativeTurnSettings: usesSupervisionConnection,
|
||||
});
|
||||
codexModelCallDiagnostics.setRequestPayloadBytes(utf8JsonByteLength(turnStartParams));
|
||||
|
||||
@@ -395,8 +395,6 @@ async function buildCodexTurnContextForTest(
|
||||
promptText: codexTurnPromptText,
|
||||
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions,
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
});
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
|
||||
@@ -1294,10 +1294,7 @@ describe("Codex app-server turn params", () => {
|
||||
params.thinkLevel = "medium";
|
||||
params.trigger = "heartbeat";
|
||||
|
||||
const heartbeatCollaborationMode = buildTurnCollaborationMode(params, {
|
||||
heartbeatCollaborationInstructions:
|
||||
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
|
||||
});
|
||||
const heartbeatCollaborationMode = buildTurnCollaborationMode(params, {});
|
||||
expect(heartbeatCollaborationMode.mode).toBe("default");
|
||||
expect(heartbeatCollaborationMode.settings.model).toBe("gpt-5.4-codex");
|
||||
expect(heartbeatCollaborationMode.settings.reasoning_effort).toBe("medium");
|
||||
@@ -1310,15 +1307,10 @@ describe("Codex app-server turn params", () => {
|
||||
expect(heartbeatCollaborationMode.settings.developer_instructions).toContain(
|
||||
"If `heartbeat_respond` is not already available and `tool_search` is available",
|
||||
);
|
||||
expect(heartbeatCollaborationMode.settings.developer_instructions).toContain(
|
||||
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.",
|
||||
);
|
||||
|
||||
params.bootstrapContextRunKind = "commitment-only";
|
||||
const commitmentCollaborationMode = buildTurnCollaborationMode(params, {
|
||||
turnScopedDeveloperInstructions: "Turn-only workspace instructions.",
|
||||
heartbeatCollaborationInstructions:
|
||||
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
|
||||
});
|
||||
expect(commitmentCollaborationMode.settings.developer_instructions).toContain(
|
||||
"# Collaboration Mode: Default",
|
||||
@@ -1329,16 +1321,11 @@ describe("Codex app-server turn params", () => {
|
||||
expect(commitmentCollaborationMode.settings.developer_instructions).not.toContain(
|
||||
"This is an OpenClaw heartbeat turn",
|
||||
);
|
||||
expect(commitmentCollaborationMode.settings.developer_instructions).not.toContain(
|
||||
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.",
|
||||
);
|
||||
|
||||
params.trigger = "user";
|
||||
expect(
|
||||
buildTurnCollaborationMode(params, {
|
||||
turnScopedDeveloperInstructions: "Turn-only workspace instructions.",
|
||||
heartbeatCollaborationInstructions:
|
||||
"HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
|
||||
}).settings.developer_instructions,
|
||||
).toContain("Turn-only workspace instructions.");
|
||||
expect(
|
||||
|
||||
@@ -28,7 +28,6 @@ export function buildTurnStartParams(
|
||||
turnScopedDeveloperInstructions?: string;
|
||||
skillsCollaborationInstructions?: string;
|
||||
memoryCollaborationInstructions?: string;
|
||||
heartbeatCollaborationInstructions?: string;
|
||||
preserveNativeTurnSettings?: boolean;
|
||||
},
|
||||
): CodexTurnStartParams {
|
||||
@@ -79,7 +78,6 @@ export function buildTurnStartParams(
|
||||
turnScopedDeveloperInstructions: options.turnScopedDeveloperInstructions,
|
||||
skillsCollaborationInstructions: options.skillsCollaborationInstructions,
|
||||
memoryCollaborationInstructions: options.memoryCollaborationInstructions,
|
||||
heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
@@ -95,7 +93,6 @@ export function buildTurnCollaborationMode(
|
||||
turnScopedDeveloperInstructions?: string;
|
||||
skillsCollaborationInstructions?: string;
|
||||
memoryCollaborationInstructions?: string;
|
||||
heartbeatCollaborationInstructions?: string;
|
||||
} = {},
|
||||
): CodexTurnCollaborationMode {
|
||||
const model = options.model ?? params.modelId;
|
||||
@@ -119,7 +116,6 @@ function buildTurnScopedCollaborationInstructions(
|
||||
turnScopedDeveloperInstructions?: string;
|
||||
skillsCollaborationInstructions?: string;
|
||||
memoryCollaborationInstructions?: string;
|
||||
heartbeatCollaborationInstructions?: string;
|
||||
} = {},
|
||||
): string | null {
|
||||
const contextInstructions = joinPresentSections(
|
||||
@@ -131,11 +127,7 @@ function buildTurnScopedCollaborationInstructions(
|
||||
return joinPresentSections(buildCronCollaborationInstructions(), contextInstructions);
|
||||
}
|
||||
if (params.trigger === "heartbeat" && params.bootstrapContextRunKind !== "commitment-only") {
|
||||
return joinPresentSections(
|
||||
buildHeartbeatCollaborationInstructions(),
|
||||
contextInstructions,
|
||||
options.heartbeatCollaborationInstructions,
|
||||
);
|
||||
return joinPresentSections(buildHeartbeatCollaborationInstructions(), contextInstructions);
|
||||
}
|
||||
if (contextInstructions?.trim()) {
|
||||
return joinPresentSections(buildDefaultCollaborationInstructions(), contextInstructions);
|
||||
|
||||
@@ -50,7 +50,6 @@ export function buildCodexHarnessPromptSnapshot(params: {
|
||||
promptText?: string;
|
||||
developerInstructionAdditions?: string;
|
||||
turnScopedDeveloperInstructions?: string;
|
||||
heartbeatCollaborationInstructions?: string;
|
||||
}): CodexHarnessPromptSnapshot {
|
||||
const developerInstructions = joinPresentSections(
|
||||
buildDeveloperInstructions(params.attempt, {
|
||||
@@ -79,7 +78,6 @@ export function buildCodexHarnessPromptSnapshot(params: {
|
||||
appServer: params.appServer,
|
||||
promptText: params.promptText,
|
||||
turnScopedDeveloperInstructions: params.turnScopedDeveloperInstructions,
|
||||
heartbeatCollaborationInstructions: params.heartbeatCollaborationInstructions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +196,10 @@ import {
|
||||
CronRemoveParamsSchema,
|
||||
CronRunParamsSchema,
|
||||
CronRunsParamsSchema,
|
||||
CronScratchGetParamsSchema,
|
||||
CronScratchGetResultSchema,
|
||||
CronScratchSetParamsSchema,
|
||||
CronScratchSetResultSchema,
|
||||
CronStatusParamsSchema,
|
||||
CronUpdateParamsSchema,
|
||||
DevicePairApproveParamsSchema,
|
||||
@@ -950,6 +954,8 @@ export const validateCronUpdateParams = lazyCompile(CronUpdateParamsSchema);
|
||||
export const validateCronRemoveParams = lazyCompile(CronRemoveParamsSchema);
|
||||
export const validateCronRunParams = lazyCompile(CronRunParamsSchema);
|
||||
export const validateCronRunsParams = lazyCompile(CronRunsParamsSchema);
|
||||
export const validateCronScratchGetParams = lazyCompile(CronScratchGetParamsSchema);
|
||||
export const validateCronScratchSetParams = lazyCompile(CronScratchSetParamsSchema);
|
||||
export const validateDevicePairListParams = lazyCompile(DevicePairListParamsSchema);
|
||||
export const validateDevicePairApproveParams = lazyCompile(DevicePairApproveParamsSchema);
|
||||
export const validateDevicePairRejectParams = lazyCompile(DevicePairRejectParamsSchema);
|
||||
@@ -1458,6 +1464,10 @@ export {
|
||||
CronRemoveParamsSchema,
|
||||
CronRunParamsSchema,
|
||||
CronRunsParamsSchema,
|
||||
CronScratchGetParamsSchema,
|
||||
CronScratchGetResultSchema,
|
||||
CronScratchSetParamsSchema,
|
||||
CronScratchSetResultSchema,
|
||||
LogsTailParamsSchema,
|
||||
LogsTailResultSchema,
|
||||
TerminalOpenParamsSchema,
|
||||
@@ -1966,6 +1976,10 @@ export type {
|
||||
CronRemoveParams,
|
||||
CronRunParams,
|
||||
CronRunsParams,
|
||||
CronScratchGetParams,
|
||||
CronScratchGetResult,
|
||||
CronScratchSetParams,
|
||||
CronScratchSetResult,
|
||||
CronRunLogEntry,
|
||||
ApprovalKind,
|
||||
ApprovalDecision,
|
||||
|
||||
@@ -566,6 +566,40 @@ export const CronStatusParamsSchema = closedObject({});
|
||||
/** Looks up a job by stable id or legacy jobId alias. */
|
||||
export const CronGetParamsSchema = cronIdOrJobIdParams({});
|
||||
|
||||
export const CronScratchSchema = closedObject({
|
||||
content: Type.String({ maxLength: 262144 }),
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
updatedAtMs: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
|
||||
/** Reads private per-job scratch without adding it to the public job schema. */
|
||||
export const CronScratchGetParamsSchema = cronIdOrJobIdParams({});
|
||||
export const CronScratchGetResultSchema = closedObject({
|
||||
scratch: Type.Union([CronScratchSchema, Type.Null()]),
|
||||
// Monotonic across unset/recreate; pass as expectedRevision for safe writes.
|
||||
currentRevision: Type.Integer({ minimum: 0 }),
|
||||
maxBytes: Type.Integer({ minimum: 1 }),
|
||||
});
|
||||
|
||||
/** Compare-and-swaps or clears private per-job scratch. */
|
||||
export const CronScratchSetParamsSchema = cronIdOrJobIdParams({
|
||||
content: Type.Union([Type.String({ maxLength: 262144 }), Type.Null()]),
|
||||
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
});
|
||||
export const CronScratchSetResultSchema = Type.Union([
|
||||
closedObject({
|
||||
ok: Type.Literal(true),
|
||||
scratch: Type.Union([CronScratchSchema, Type.Null()]),
|
||||
currentRevision: Type.Integer({ minimum: 0 }),
|
||||
maxBytes: Type.Integer({ minimum: 1 }),
|
||||
}),
|
||||
closedObject({
|
||||
ok: Type.Literal(false),
|
||||
reason: Type.Literal("revision-conflict"),
|
||||
currentRevision: Type.Integer({ minimum: 0 }),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Creates a scheduled job with schedule, target, payload, and delivery policy. */
|
||||
export const CronAddParamsSchema = closedObject({
|
||||
name: NonEmptyString,
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
CronRunLogEntrySchema,
|
||||
CronRunParamsSchema,
|
||||
CronRunsParamsSchema,
|
||||
CronScratchGetParamsSchema,
|
||||
CronScratchGetResultSchema,
|
||||
CronScratchSetParamsSchema,
|
||||
CronScratchSetResultSchema,
|
||||
CronStatusParamsSchema,
|
||||
CronUpdateParamsSchema,
|
||||
} from "./cron.js";
|
||||
@@ -26,4 +30,8 @@ export type CronUpdateParams = Static<typeof CronUpdateParamsSchema>;
|
||||
export type CronRemoveParams = Static<typeof CronRemoveParamsSchema>;
|
||||
export type CronRunParams = Static<typeof CronRunParamsSchema>;
|
||||
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
||||
export type CronScratchGetParams = Static<typeof CronScratchGetParamsSchema>;
|
||||
export type CronScratchGetResult = Static<typeof CronScratchGetResultSchema>;
|
||||
export type CronScratchSetParams = Static<typeof CronScratchSetParamsSchema>;
|
||||
export type CronScratchSetResult = Static<typeof CronScratchSetResultSchema>;
|
||||
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
||||
|
||||
@@ -268,6 +268,10 @@ import {
|
||||
CronRunLogEntrySchema,
|
||||
CronRunParamsSchema,
|
||||
CronRunsParamsSchema,
|
||||
CronScratchGetParamsSchema,
|
||||
CronScratchGetResultSchema,
|
||||
CronScratchSetParamsSchema,
|
||||
CronScratchSetResultSchema,
|
||||
CronStatusParamsSchema,
|
||||
CronUpdateParamsSchema,
|
||||
} from "./cron.js";
|
||||
@@ -1125,6 +1129,10 @@ export const ProtocolSchemas = {
|
||||
CronRemoveParams: CronRemoveParamsSchema,
|
||||
CronRunParams: CronRunParamsSchema,
|
||||
CronRunsParams: CronRunsParamsSchema,
|
||||
CronScratchGetParams: CronScratchGetParamsSchema,
|
||||
CronScratchGetResult: CronScratchGetResultSchema,
|
||||
CronScratchSetParams: CronScratchSetParamsSchema,
|
||||
CronScratchSetResult: CronScratchSetResultSchema,
|
||||
CronRunLogEntry: CronRunLogEntrySchema,
|
||||
...LogMigrationProtocolSchemas,
|
||||
...TerminalProtocolSchemas,
|
||||
|
||||
@@ -17,13 +17,14 @@ export const WORKSPACE_TEMPLATE_PACK_PATHS = [
|
||||
"docs/reference/templates/BOOTSTRAP.md",
|
||||
];
|
||||
|
||||
// HEARTBEAT.md ships in the template pack for docs/doctor context but is no
|
||||
// longer seeded into new workspaces; heartbeat context lives in cron scratch.
|
||||
const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
];
|
||||
|
||||
|
||||
@@ -190,7 +190,6 @@ describe("resolveBootstrapFilesForRun", () => {
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
]);
|
||||
expect(warnings).toHaveLength(3);
|
||||
@@ -398,9 +397,8 @@ describe("resolveBootstrapContextForRun", () => {
|
||||
expect(contextFileNames.has("AGENTS.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses heartbeat-only bootstrap files in lightweight heartbeat mode", async () => {
|
||||
it("keeps bootstrap context empty in lightweight heartbeat mode", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
|
||||
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "persona", "utf8");
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({
|
||||
@@ -409,8 +407,8 @@ describe("resolveBootstrapContextForRun", () => {
|
||||
runKind: "heartbeat",
|
||||
});
|
||||
|
||||
expect(files.map((file) => file.name)).toStrictEqual(["HEARTBEAT.md"]);
|
||||
expect(files[0]?.content).toBe("check inbox");
|
||||
// Heartbeat context comes from cron scratch via the heartbeat runner now.
|
||||
expect(files).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bootstrap context empty in lightweight cron mode", async () => {
|
||||
@@ -440,48 +438,9 @@ describe("resolveBootstrapContextForRun", () => {
|
||||
expect(files.map((file) => file.name)).toContain("SOUL.md");
|
||||
});
|
||||
|
||||
it("keeps HEARTBEAT.md for non-heartbeat runs when heartbeat cadence is enabled", async () => {
|
||||
it("never re-imports a leftover workspace HEARTBEAT.md into bootstrap context", async () => {
|
||||
const workspaceDir = await createHeartbeatAgentsWorkspace();
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({
|
||||
workspaceDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(files.map((file) => file.name)).toContain("HEARTBEAT.md");
|
||||
});
|
||||
|
||||
it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat cadence is disabled", async () => {
|
||||
const workspaceDir = await createHeartbeatAgentsWorkspace();
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({
|
||||
workspaceDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "0m",
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectHeartbeatExcludedAndAgentsKept(files);
|
||||
});
|
||||
|
||||
it("keeps HEARTBEAT.md for actual heartbeat runs", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
|
||||
|
||||
const files = await resolveBootstrapFilesForRun({
|
||||
workspaceDir,
|
||||
runKind: "heartbeat",
|
||||
@@ -493,8 +452,7 @@ describe("resolveBootstrapContextForRun", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const fileNames = files.map((file) => file.name);
|
||||
expect(fileNames).toContain("HEARTBEAT.md");
|
||||
expectHeartbeatExcludedAndAgentsKept(files);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { AgentContextInjection } from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { readFileWindowFully } from "../infra/file-read.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
|
||||
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
||||
import type { BootstrapContextRunKind } from "./bootstrap-mode.js";
|
||||
@@ -20,9 +20,7 @@ import {
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
} from "./embedded-agent-helpers.js";
|
||||
import { shouldIncludeHeartbeatGuidanceForSystemPrompt } from "./heartbeat-system-prompt.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
filterBootstrapFilesForSession,
|
||||
isWorkspaceSetupCompleted,
|
||||
@@ -203,55 +201,14 @@ function applyContextModeFilter(params: {
|
||||
runKind?: BootstrapContextRunKind;
|
||||
}): WorkspaceBootstrapFile[] {
|
||||
const contextMode = params.contextMode ?? "full";
|
||||
const runKind = params.runKind ?? "default";
|
||||
if (contextMode !== "lightweight") {
|
||||
return params.files;
|
||||
}
|
||||
if (runKind === "heartbeat") {
|
||||
return params.files.filter((file) => file.name === "HEARTBEAT.md");
|
||||
}
|
||||
// cron/default lightweight mode keeps bootstrap context empty on purpose.
|
||||
// Heartbeat scratch is injected by the heartbeat runner, not bootstrap files.
|
||||
// Cron/default lightweight mode also keeps bootstrap context empty on purpose.
|
||||
return [];
|
||||
}
|
||||
|
||||
function shouldExcludeHeartbeatBootstrapFile(params: {
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
runKind?: BootstrapContextRunKind;
|
||||
}): boolean {
|
||||
if (params.runKind === "commitment-only") {
|
||||
return true;
|
||||
}
|
||||
if (!params.config || params.runKind === "heartbeat") {
|
||||
return false;
|
||||
}
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
if (sessionAgentId !== defaultAgentId) {
|
||||
return false;
|
||||
}
|
||||
return !shouldIncludeHeartbeatGuidanceForSystemPrompt({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
defaultAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
function filterHeartbeatBootstrapFile(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
excludeHeartbeatBootstrapFile: boolean,
|
||||
): WorkspaceBootstrapFile[] {
|
||||
if (!excludeHeartbeatBootstrapFile) {
|
||||
return files;
|
||||
}
|
||||
return files.filter((file) => file.name !== DEFAULT_HEARTBEAT_FILENAME);
|
||||
}
|
||||
|
||||
function filterCompletedWorkspaceBootstrapFile(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
setupCompleted: boolean,
|
||||
@@ -298,7 +255,6 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
contextMode?: BootstrapContextMode;
|
||||
runKind?: BootstrapContextRunKind;
|
||||
}): Promise<WorkspaceBootstrapFile[]> {
|
||||
const excludeHeartbeatBootstrapFile = shouldExcludeHeartbeatBootstrapFile(params);
|
||||
const sessionKey = params.sessionKey ?? params.sessionId;
|
||||
const workspaceSetupCompleted = await isWorkspaceSetupCompletedForContext(params.workspaceDir);
|
||||
const rawFiles = params.sessionKey
|
||||
@@ -330,11 +286,7 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
workspaceSetupCompleted,
|
||||
params.workspaceDir,
|
||||
);
|
||||
return sanitizeBootstrapFiles(
|
||||
filterHeartbeatBootstrapFile(filteredUpdated, excludeHeartbeatBootstrapFile),
|
||||
params.workspaceDir,
|
||||
params.warn,
|
||||
);
|
||||
return sanitizeBootstrapFiles(filteredUpdated, params.workspaceDir, params.warn);
|
||||
}
|
||||
|
||||
/** Resolves both raw bootstrap metadata and bounded context files for a run. */
|
||||
|
||||
@@ -128,13 +128,13 @@ describe("buildBootstrapContextFiles", () => {
|
||||
const maxChars = EXPECTED_DEFAULT_BOOTSTRAP_MAX_CHARS;
|
||||
const files = [
|
||||
makeFile({
|
||||
name: "HEARTBEAT.md",
|
||||
path: "/tmp/HEARTBEAT.md",
|
||||
name: "USER.md",
|
||||
path: "/tmp/USER.md",
|
||||
content: "a".repeat(maxChars * 2),
|
||||
}),
|
||||
];
|
||||
const [result] = buildBootstrapContextFiles(files, { maxChars });
|
||||
expect(result?.content).toContain("[...truncated, read HEARTBEAT.md for full content...]");
|
||||
expect(result?.content).toContain("[...truncated, read USER.md for full content...]");
|
||||
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
|
||||
});
|
||||
it("keeps policy digest lines from oversized AGENTS.md middle content", () => {
|
||||
@@ -164,8 +164,8 @@ describe("buildBootstrapContextFiles", () => {
|
||||
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;
|
||||
const files = [
|
||||
makeFile({
|
||||
name: "HEARTBEAT.md",
|
||||
path: "/tmp/HEARTBEAT.md",
|
||||
name: "USER.md",
|
||||
path: "/tmp/USER.md",
|
||||
content,
|
||||
}),
|
||||
];
|
||||
@@ -180,8 +180,8 @@ describe("buildBootstrapContextFiles", () => {
|
||||
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;
|
||||
const files = [
|
||||
makeFile({
|
||||
name: "HEARTBEAT.md",
|
||||
path: "/tmp/HEARTBEAT.md",
|
||||
name: "USER.md",
|
||||
path: "/tmp/USER.md",
|
||||
content,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -30,7 +30,7 @@ Live chat: short, natural, human. No memo voice, long preamble, wall, repetition
|
||||
/** @deprecated OpenAI/Codex provider-owned prompt overlay helper; do not use from third-party plugins. */
|
||||
export const GPT5_HEARTBEAT_PROMPT_OVERLAY = `### Heartbeats
|
||||
|
||||
Heartbeat = useful proactive progress, not chatter. Wake, orient, read HEARTBEAT.md, act.
|
||||
Heartbeat = useful proactive progress, not chatter. Wake, orient, use the provided monitor scratch, act.
|
||||
Assigned/ongoing work: pursue spirit with judgment. Quiet check counts only if real blocker/urgent interruption.
|
||||
No rote loops; orientation != accomplishment. Prefer action/silent progress.
|
||||
Never repetitive "same/no change/still" updates.
|
||||
|
||||
@@ -59,7 +59,7 @@ function isHeartbeatCadenceEnabled(heartbeat?: HeartbeatConfig): boolean {
|
||||
}
|
||||
|
||||
/** Returns true when heartbeat guidance should be included in the system prompt. */
|
||||
export function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: {
|
||||
function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
defaultAgentId?: string;
|
||||
|
||||
@@ -81,9 +81,9 @@ const CONTEXT_FILE_ORDER = new Map<string, number>([
|
||||
["memory.md", 70],
|
||||
]);
|
||||
|
||||
const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set(["heartbeat.md"]);
|
||||
const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set<string>();
|
||||
const DEFAULT_HEARTBEAT_PROMPT_CONTEXT_BLOCK =
|
||||
"Default heartbeat prompt:\n`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`";
|
||||
"Default heartbeat prompt:\n`Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`";
|
||||
const SYSTEM_PROMPT_STABLE_PREFIX_CACHE_LIMIT = 64;
|
||||
|
||||
type StablePromptPrefixCacheEntry = {
|
||||
|
||||
@@ -21,6 +21,7 @@ type HeartbeatResponseDetails = {
|
||||
notificationText?: string;
|
||||
priority?: string;
|
||||
nextCheck?: string;
|
||||
scratch?: string;
|
||||
};
|
||||
|
||||
describe("createHeartbeatResponseTool", () => {
|
||||
@@ -77,6 +78,26 @@ describe("createHeartbeatResponseTool", () => {
|
||||
).rejects.toThrow("heartbeat_respond already recorded");
|
||||
});
|
||||
|
||||
it("captures scratch without echoing future prompt content to the model", async () => {
|
||||
const tool = createHeartbeatResponseTool();
|
||||
const scratch = "Private monitor context that must not enter tool output.";
|
||||
|
||||
const result = await tool.execute("call-1", {
|
||||
outcome: "progress",
|
||||
notify: false,
|
||||
summary: "Updated monitor context.",
|
||||
scratch,
|
||||
});
|
||||
|
||||
const details = result.details as HeartbeatResponseDetails;
|
||||
expect(details.scratch).toBe(scratch);
|
||||
expect(JSON.stringify(result.content)).not.toContain(scratch);
|
||||
expect(JSON.stringify(details)).not.toContain(scratch);
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({ text: expect.stringContaining('"scratchPending": true') }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts notification text and optional scheduling metadata", async () => {
|
||||
const tool = createHeartbeatResponseTool();
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
HEARTBEAT_TOOL_PRIORITIES,
|
||||
normalizeHeartbeatToolResponse,
|
||||
} from "../../auto-reply/heartbeat-tool-response.js";
|
||||
import { assertCronJobScratchContent } from "../../cron/scratch-contract.js";
|
||||
import { readSnakeCaseParamRaw } from "../../param-key.js";
|
||||
import { optionalStringEnum, stringEnum } from "../schema/string-enum.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, ToolInputError } from "./common.js";
|
||||
import { textResult, ToolInputError } from "./common.js";
|
||||
|
||||
const HeartbeatResponseToolSchema = Type.Object(
|
||||
{
|
||||
@@ -26,6 +27,7 @@ const HeartbeatResponseToolSchema = Type.Object(
|
||||
reason: Type.Optional(Type.String()),
|
||||
priority: optionalStringEnum(HEARTBEAT_TOOL_PRIORITIES),
|
||||
nextCheck: Type.Optional(Type.String()),
|
||||
scratch: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -53,6 +55,13 @@ export function createHeartbeatResponseTool(): AnyAgentTool {
|
||||
throw new ToolInputError("Heartbeat response arguments required");
|
||||
}
|
||||
readRequiredBoolean(args, "notify");
|
||||
if (typeof args.scratch === "string") {
|
||||
try {
|
||||
assertCronJobScratchContent(args.scratch);
|
||||
} catch (error) {
|
||||
throw new ToolInputError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
const response = normalizeHeartbeatToolResponse(args);
|
||||
if (!response) {
|
||||
throw new ToolInputError(
|
||||
@@ -65,10 +74,34 @@ export function createHeartbeatResponseTool(): AnyAgentTool {
|
||||
throw new ToolInputError("heartbeat_respond already recorded for this turn");
|
||||
}
|
||||
recorded = true;
|
||||
return jsonResult({
|
||||
status: "recorded",
|
||||
...response,
|
||||
});
|
||||
const { scratch, ...publicResponse } = response;
|
||||
const details = { status: "recorded" as const, ...publicResponse } as typeof response & {
|
||||
status: "recorded";
|
||||
};
|
||||
if (scratch !== undefined) {
|
||||
// Keep future prompt content out of model-visible tool output and logs;
|
||||
// the runner receives it through the internal result details only.
|
||||
Object.defineProperty(details, "scratch", { value: scratch, enumerable: false });
|
||||
}
|
||||
return textResult(
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "recorded",
|
||||
...publicResponse,
|
||||
...(scratch !== undefined
|
||||
? {
|
||||
// Persistence is a runner-side CAS after the turn; do not claim
|
||||
// success here. A lost race is logged and retryable next beat.
|
||||
scratchPending: true,
|
||||
scratchBytes: Buffer.byteLength(scratch, "utf8"),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
details,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -826,19 +826,13 @@ describe("ensureAgentWorkspace", () => {
|
||||
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("writes the clean HEARTBEAT runtime template into new workspaces", async () => {
|
||||
it("no longer seeds HEARTBEAT.md into new workspaces", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
const heartbeat = await fs.readFile(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME), "utf-8");
|
||||
expect(heartbeat).not.toContain("```");
|
||||
expect(heartbeat).toContain(
|
||||
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
|
||||
);
|
||||
expect(heartbeat).toContain(
|
||||
"# Add tasks below when you want the agent to check something periodically.",
|
||||
);
|
||||
// Heartbeat monitor context lives in cron scratch now; new workspaces get no file.
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
|
||||
});
|
||||
|
||||
it("does not recreate optional bootstrap files when workspace setup is already completed", async () => {
|
||||
@@ -870,7 +864,6 @@ describe("ensureAgentWorkspace", () => {
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_SOUL_FILENAME));
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_USER_FILENAME));
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
@@ -1148,7 +1141,6 @@ describe("filterBootstrapFilesForSession", () => {
|
||||
{ name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false },
|
||||
{ name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false },
|
||||
{ name: "USER.md", path: "/w/USER.md", content: "", missing: false },
|
||||
{ name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false },
|
||||
{ name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false },
|
||||
{ name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false },
|
||||
];
|
||||
|
||||
+8
-30
@@ -38,10 +38,7 @@ import {
|
||||
type WorkspaceStateSnapshot,
|
||||
type WorkspaceSetupState,
|
||||
} from "./workspace-state-store.js";
|
||||
import {
|
||||
resolveWorkspaceTemplateDir,
|
||||
resolveWorkspaceTemplateSearchDirs,
|
||||
} from "./workspace-templates.js";
|
||||
import { resolveWorkspaceTemplateSearchDirs } from "./workspace-templates.js";
|
||||
export {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
resolveDefaultAgentWorkspaceDir,
|
||||
@@ -150,10 +147,7 @@ async function loadTemplate(name: string): Promise<string> {
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
const templateDirs =
|
||||
name === DEFAULT_HEARTBEAT_FILENAME
|
||||
? [await resolveWorkspaceTemplateDir()]
|
||||
: await resolveWorkspaceTemplateSearchDirs();
|
||||
const templateDirs = await resolveWorkspaceTemplateSearchDirs();
|
||||
const triedPaths: string[] = [];
|
||||
for (const templateDir of templateDirs) {
|
||||
const templatePath = path.join(templateDir, name);
|
||||
@@ -187,7 +181,6 @@ export type WorkspaceBootstrapFileName =
|
||||
| typeof DEFAULT_TOOLS_FILENAME
|
||||
| typeof DEFAULT_IDENTITY_FILENAME
|
||||
| typeof DEFAULT_USER_FILENAME
|
||||
| typeof DEFAULT_HEARTBEAT_FILENAME
|
||||
| typeof DEFAULT_BOOTSTRAP_FILENAME
|
||||
| typeof DEFAULT_MEMORY_FILENAME;
|
||||
|
||||
@@ -217,7 +210,6 @@ const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
DEFAULT_MEMORY_FILENAME,
|
||||
]);
|
||||
@@ -226,7 +218,6 @@ const OPTIONAL_BOOTSTRAP_FILENAMES: ReadonlySet<string> = new Set([
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
]);
|
||||
|
||||
export const WORKSPACE_VANISHED_ERROR_CODE = "WORKSPACE_VANISHED";
|
||||
@@ -383,7 +374,7 @@ async function workspaceRequiredBootstrapLooksCustomized(
|
||||
dir: string,
|
||||
opts?: { generatedHashes?: ReadonlyMap<string, string> },
|
||||
): Promise<boolean> {
|
||||
const fileNames = [DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_HEARTBEAT_FILENAME];
|
||||
const fileNames = [DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME];
|
||||
const generatedHashes = opts?.generatedHashes;
|
||||
if (generatedHashes && generatedHashes.size > 0) {
|
||||
for (const fileName of fileNames) {
|
||||
@@ -499,7 +490,6 @@ async function collectGeneratedBootstrapHashes(dir: string): Promise<Map<string,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
];
|
||||
for (const fileName of fileNames) {
|
||||
try {
|
||||
@@ -612,7 +602,6 @@ async function workspaceSetupStateHasSurvivalEvidence(params: {
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
].every((fileName) => generatedHashes.has(fileName));
|
||||
}
|
||||
|
||||
@@ -697,7 +686,7 @@ export async function ensureAgentWorkspace(params?: {
|
||||
ensureBootstrapFiles?: boolean;
|
||||
/**
|
||||
* List of optional bootstrap filenames to skip writing.
|
||||
* Applies only to SOUL.md, USER.md, HEARTBEAT.md, IDENTITY.md.
|
||||
* Applies only to SOUL.md, USER.md, IDENTITY.md.
|
||||
* Required workspace setup such as AGENTS.md and TOOLS.md still runs.
|
||||
*/
|
||||
skipOptionalBootstrapFiles?: string[];
|
||||
@@ -708,7 +697,6 @@ export async function ensureAgentWorkspace(params?: {
|
||||
toolsPath?: string;
|
||||
identityPath?: string;
|
||||
userPath?: string;
|
||||
heartbeatPath?: string;
|
||||
bootstrapPath?: string;
|
||||
bootstrapPending?: boolean;
|
||||
identityPathCreated?: boolean;
|
||||
@@ -765,10 +753,9 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME);
|
||||
const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME);
|
||||
const userPath = path.join(dir, DEFAULT_USER_FILENAME);
|
||||
const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
|
||||
const isBrandNewWorkspace = await (async () => {
|
||||
const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
|
||||
const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath];
|
||||
const paths = [...templatePaths, path.join(dir, "memory")];
|
||||
const existing = await Promise.all(
|
||||
paths.map(async (p) => {
|
||||
@@ -835,15 +822,14 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME);
|
||||
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
|
||||
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
|
||||
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
|
||||
// Template and filesystem checks above are async. Another process may have
|
||||
// completed setup while they ran, so optional-file policy needs fresh state.
|
||||
initialState = readCanonicalWorkspaceStateSnapshot(dir);
|
||||
const skipOptionalBootstrapFiles = new Set(params?.skipOptionalBootstrapFiles ?? []);
|
||||
// When the workspace is already configured, skip optional bootstrap files to
|
||||
// prevent subagent spawns from recreating root-level SOUL.md, USER.md,
|
||||
// IDENTITY.md, or HEARTBEAT.md that were removed intentionally or only exist
|
||||
// under agent-specific subdirectories.
|
||||
// prevent subagent spawns from recreating root-level SOUL.md, USER.md, or
|
||||
// IDENTITY.md that were removed intentionally or only exist under agent-specific
|
||||
// subdirectories.
|
||||
if (initialState.setup.setupCompletedAt) {
|
||||
for (const filename of OPTIONAL_BOOTSTRAP_FILENAMES) {
|
||||
skipOptionalBootstrapFiles.add(filename);
|
||||
@@ -863,9 +849,6 @@ export async function ensureAgentWorkspace(params?: {
|
||||
if (shouldWriteBootstrapFile(DEFAULT_USER_FILENAME)) {
|
||||
await writeFileIfMissing(userPath, userTemplate);
|
||||
}
|
||||
if (shouldWriteBootstrapFile(DEFAULT_HEARTBEAT_FILENAME)) {
|
||||
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
|
||||
}
|
||||
|
||||
let state = readCanonicalWorkspaceStateSnapshot(dir).setup;
|
||||
let stateDirty = false;
|
||||
@@ -939,7 +922,6 @@ export async function ensureAgentWorkspace(params?: {
|
||||
toolsPath,
|
||||
identityPath,
|
||||
userPath,
|
||||
heartbeatPath,
|
||||
bootstrapPath,
|
||||
bootstrapPending: !state.setupCompletedAt && bootstrapExists,
|
||||
identityPathCreated,
|
||||
@@ -973,10 +955,6 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
|
||||
name: DEFAULT_USER_FILENAME,
|
||||
filePath: path.join(resolvedDir, DEFAULT_USER_FILENAME),
|
||||
},
|
||||
{
|
||||
name: DEFAULT_HEARTBEAT_FILENAME,
|
||||
filePath: path.join(resolvedDir, DEFAULT_HEARTBEAT_FILENAME),
|
||||
},
|
||||
{
|
||||
name: DEFAULT_BOOTSTRAP_FILENAME,
|
||||
filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHeartbeatToolResponsePayload,
|
||||
resolveHeartbeatScratchProposalFromReplyResult,
|
||||
} from "./heartbeat-tool-response.js";
|
||||
|
||||
describe("heartbeat scratch proposal resolution", () => {
|
||||
it("lets a later heartbeat response clear an earlier scratch proposal", () => {
|
||||
const first = createHeartbeatToolResponsePayload({
|
||||
outcome: "progress",
|
||||
notify: false,
|
||||
summary: "first",
|
||||
scratch: "stale scratch",
|
||||
});
|
||||
const corrected = createHeartbeatToolResponsePayload({
|
||||
outcome: "no_change",
|
||||
notify: false,
|
||||
summary: "corrected",
|
||||
});
|
||||
|
||||
expect(resolveHeartbeatScratchProposalFromReplyResult([first, corrected])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// Structured heartbeat response tool payload helpers.
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { assertCronJobScratchContent } from "../cron/scratch-contract.js";
|
||||
import { readTrimmedStringAlias } from "../utils/string-readers.js";
|
||||
import type { ReplyPayload } from "./reply-payload.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
@@ -8,6 +9,8 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
/** Tool name used by heartbeat runs to report visible or silent progress. */
|
||||
export const HEARTBEAT_RESPONSE_TOOL_NAME = "heartbeat_respond";
|
||||
const HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY = "openclawHeartbeatResponse";
|
||||
const HEARTBEAT_SCRATCH_PROPOSAL = Symbol("openclawHeartbeatScratchProposal");
|
||||
type HeartbeatReplyPayload = ReplyPayload & { [HEARTBEAT_SCRATCH_PROPOSAL]?: string };
|
||||
|
||||
/** Allowed heartbeat response outcomes. */
|
||||
export const HEARTBEAT_TOOL_OUTCOMES = [
|
||||
@@ -32,6 +35,8 @@ export type HeartbeatToolResponse = {
|
||||
reason?: string;
|
||||
priority?: HeartbeatToolPriority;
|
||||
nextCheck?: string;
|
||||
/** Complete replacement for the current heartbeat monitor's private scratch. */
|
||||
scratch?: string;
|
||||
};
|
||||
|
||||
const OUTCOMES = new Set<string>(HEARTBEAT_TOOL_OUTCOMES);
|
||||
@@ -63,6 +68,14 @@ export function normalizeHeartbeatToolResponse(value: unknown): HeartbeatToolRes
|
||||
const notificationText = readTrimmedStringAlias(value, ["notificationText", "notification_text"]);
|
||||
const reason = readString(value.reason);
|
||||
const nextCheck = readTrimmedStringAlias(value, ["nextCheck", "next_check"]);
|
||||
const scratch = typeof value.scratch === "string" ? value.scratch : undefined;
|
||||
if (scratch !== undefined) {
|
||||
try {
|
||||
assertCronJobScratchContent(scratch);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: outcome as HeartbeatToolOutcome,
|
||||
notify,
|
||||
@@ -73,6 +86,7 @@ export function normalizeHeartbeatToolResponse(value: unknown): HeartbeatToolRes
|
||||
? { priority: priority as HeartbeatToolPriority }
|
||||
: {}),
|
||||
...(nextCheck ? { nextCheck } : {}),
|
||||
...(scratch !== undefined ? { scratch } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,14 +95,24 @@ export function getHeartbeatToolNotificationText(response: HeartbeatToolResponse
|
||||
return response.notify ? (response.notificationText ?? response.summary).trim() : "";
|
||||
}
|
||||
|
||||
/** Store a heartbeat tool response in reply channel data for later extraction. */
|
||||
export function createHeartbeatToolResponsePayload(response: HeartbeatToolResponse): ReplyPayload {
|
||||
return {
|
||||
/** Store public heartbeat response metadata while keeping scratch process-private. */
|
||||
export function createHeartbeatToolResponsePayload(
|
||||
response: HeartbeatToolResponse,
|
||||
): HeartbeatReplyPayload {
|
||||
const { scratch, ...publicResponse } = response;
|
||||
const payload: HeartbeatReplyPayload = {
|
||||
text: response.notify ? getHeartbeatToolNotificationText(response) : HEARTBEAT_TOKEN,
|
||||
channelData: {
|
||||
[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY]: response,
|
||||
[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY]: publicResponse,
|
||||
},
|
||||
};
|
||||
if (scratch !== undefined) {
|
||||
Object.defineProperty(payload, HEARTBEAT_SCRATCH_PROPOSAL, {
|
||||
value: scratch,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getHeartbeatToolResponseFromPayload(
|
||||
@@ -115,3 +139,24 @@ export function resolveHeartbeatToolResponseFromReplyResult(
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Reads the non-serializable scratch proposal captured for the heartbeat turn. */
|
||||
export function resolveHeartbeatScratchProposalFromReplyResult(
|
||||
replyResult: ReplyPayload | ReplyPayload[] | undefined,
|
||||
): string | undefined {
|
||||
if (!replyResult) {
|
||||
return undefined;
|
||||
}
|
||||
const payloads = Array.isArray(replyResult) ? replyResult : [replyResult];
|
||||
for (let idx = payloads.length - 1; idx >= 0; idx -= 1) {
|
||||
const payload = payloads[idx];
|
||||
// Anchor to the newest heartbeat-response payload: a later corrected
|
||||
// response without scratch must supersede an earlier scratch proposal,
|
||||
// so the scan stops at the first response payload either way.
|
||||
if (!getHeartbeatToolResponseFromPayload(payload)) {
|
||||
continue;
|
||||
}
|
||||
return (payload as HeartbeatReplyPayload | undefined)?.[HEARTBEAT_SCRATCH_PROPOSAL];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { escapeRegExp } from "../shared/regexp.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
/** YAML-like task entry parsed from HEARTBEAT.md. */
|
||||
/** YAML-like task entry parsed from heartbeat monitor scratch. */
|
||||
export type HeartbeatTask = {
|
||||
name: string;
|
||||
interval: string;
|
||||
@@ -14,7 +14,7 @@ export type HeartbeatTask = {
|
||||
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
|
||||
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
|
||||
const HEARTBEAT_CONTEXT_PROMPT =
|
||||
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats.";
|
||||
"Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats.";
|
||||
/** Default prompt for heartbeat turns when config does not override it. */
|
||||
export const HEARTBEAT_PROMPT = `${HEARTBEAT_CONTEXT_PROMPT} If nothing needs attention, reply HEARTBEAT_OK.`;
|
||||
export const HEARTBEAT_RESPONSE_TOOL_INSTRUCTIONS =
|
||||
@@ -54,7 +54,7 @@ function stripHeartbeatHtmlComments(content: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
|
||||
* Check if heartbeat scratch is "effectively empty" - meaning it has no actionable tasks.
|
||||
* This allows skipping heartbeat API calls when no tasks are configured.
|
||||
*
|
||||
* A file is considered effectively empty if it contains only:
|
||||
@@ -65,8 +65,8 @@ function stripHeartbeatHtmlComments(content: string): string[] {
|
||||
* - Markdown fence markers such as ``` or ```markdown
|
||||
* - Empty list item stubs (`- `, `- [ ]`, `* `, `+ `)
|
||||
*
|
||||
* Note: A missing file returns false (not effectively empty) so the LLM can still
|
||||
* decide what to do. This function is only for when the file exists but has no content.
|
||||
* Note: Missing scratch returns false (not effectively empty) so the model can
|
||||
* still decide what to do. This function applies only when a scratch row exists.
|
||||
*/
|
||||
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||
if (content === undefined || content === null) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Cron scratch CLI: private per-job prompt context reads and compare-and-swap writes.
|
||||
import type { Command } from "commander";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { handleCronCliError, printCronJson } from "./shared.js";
|
||||
import { readCronScratchContent } from "./trigger-options.js";
|
||||
|
||||
type ScratchRecord = { content: string; revision: number; updatedAtMs: number };
|
||||
type ScratchGetResult = {
|
||||
scratch: ScratchRecord | null;
|
||||
currentRevision: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
type ScratchSetResult =
|
||||
| { ok: true; scratch: ScratchRecord | null; currentRevision: number; maxBytes: number }
|
||||
| { ok: false; reason: "revision-conflict"; currentRevision: number };
|
||||
|
||||
function parseExpectedRevision(value: string | undefined): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const revision = Number(value);
|
||||
if (!Number.isSafeInteger(revision) || revision < 0) {
|
||||
throw new Error("--expected-revision must be a non-negative integer");
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
export function registerCronScratchCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("scratch")
|
||||
.description("Read or replace a cron job's private scratch")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--set <text>", "Replace scratch with exact text")
|
||||
.option("--file <path>", "Replace scratch from a file, or - for stdin")
|
||||
.option("--unset", "Remove the scratch row", false)
|
||||
.option("--expected-revision <n>", "Require the current scratch revision")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const mutations = [
|
||||
opts.set !== undefined,
|
||||
opts.file !== undefined,
|
||||
opts.unset === true,
|
||||
].filter(Boolean).length;
|
||||
if (mutations > 1) {
|
||||
throw new Error("choose only one of --set, --file, or --unset");
|
||||
}
|
||||
const current = (await callGatewayFromCli("cron.scratch.get", opts, {
|
||||
id: String(id),
|
||||
})) as ScratchGetResult;
|
||||
if (mutations === 0) {
|
||||
if (opts.json) {
|
||||
printCronJson(current);
|
||||
} else if (current.scratch) {
|
||||
process.stdout.write(current.scratch.content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitRevision = parseExpectedRevision(opts.expectedRevision);
|
||||
const expectedRevision = explicitRevision ?? current.currentRevision;
|
||||
const content = opts.unset
|
||||
? null
|
||||
: opts.file !== undefined
|
||||
? await readCronScratchContent(String(opts.file))
|
||||
: String(opts.set ?? "");
|
||||
const result = (await callGatewayFromCli("cron.scratch.set", opts, {
|
||||
id: String(id),
|
||||
content,
|
||||
expectedRevision,
|
||||
})) as ScratchSetResult;
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`cron scratch changed concurrently (current revision ${result.currentRevision})`,
|
||||
);
|
||||
}
|
||||
printCronJson(result);
|
||||
} catch (error) {
|
||||
handleCronCliError(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
registerCronStatusCommand,
|
||||
} from "./register.cron-add.js";
|
||||
import { registerCronEditCommand } from "./register.cron-edit.js";
|
||||
import { registerCronScratchCommand } from "./register.cron-scratch.js";
|
||||
import { registerCronSimpleCommands } from "./register.cron-simple.js";
|
||||
|
||||
export function registerCronCli(program: Command) {
|
||||
@@ -25,6 +26,7 @@ export function registerCronCli(program: Command) {
|
||||
registerCronListCommand(cron);
|
||||
registerCronAddCommand(cron);
|
||||
registerCronSimpleCommands(cron);
|
||||
registerCronScratchCommand(cron);
|
||||
registerCronEditCommand(cron);
|
||||
|
||||
applyParentDefaultHelpAction(cron);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Client-side trigger script loading for cron create/edit commands.
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
|
||||
import { CRON_JOB_SCRATCH_MAX_BYTES } from "../../cron/scratch-contract.js";
|
||||
|
||||
const MAX_CRON_TRIGGER_SCRIPT_BYTES = 65_536;
|
||||
|
||||
@@ -41,3 +42,16 @@ export async function readCronPayloadScript(
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
/** Reads exact scratch content locally; empty content is a meaningful value. */
|
||||
export async function readCronScratchContent(
|
||||
source: string,
|
||||
deps?: { stdin?: AsyncIterable<unknown> },
|
||||
): Promise<string> {
|
||||
const stream = source === "-" ? (deps?.stdin ?? process.stdin) : createReadStream(source);
|
||||
const bytes = await readByteStreamWithLimit(stream, {
|
||||
maxBytes: CRON_JOB_SCRATCH_MAX_BYTES,
|
||||
onOverflow: () => new Error(`Cron scratch exceeds ${CRON_JOB_SCRATCH_MAX_BYTES} bytes`),
|
||||
});
|
||||
return bytes.toString("utf8");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { heartbeatMonitorAgentId } from "../cron/heartbeat-monitor.js";
|
||||
import { readCronJobScratchState, writeCronJobScratch } from "../cron/scratch-store.js";
|
||||
import {
|
||||
loadCronJobsStore,
|
||||
resolveCronJobsStorePath,
|
||||
resolveCronJobsStorePathFromConfig,
|
||||
} from "../cron/store.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
collectHeartbeatScratchMigrationFindings,
|
||||
maybeMigrateHeartbeatFilesToScratch,
|
||||
} from "./doctor-heartbeat-scratch-migration.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
let originalHome: string | undefined;
|
||||
let originalStateDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalHome = process.env.HOME;
|
||||
originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createFixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-migration-"));
|
||||
tempDirs.push(root);
|
||||
const home = path.join(root, "home");
|
||||
const stateDir = path.join(root, "state");
|
||||
const workspace = path.join(root, "workspace");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
process.env.HOME = home;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { heartbeat: { every: "30m" } },
|
||||
list: [{ id: "main", workspace }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
return { root, stateDir, workspace, cfg, heartbeatPath: path.join(workspace, "HEARTBEAT.md") };
|
||||
}
|
||||
|
||||
async function loadMonitor(cfg?: OpenClawConfig) {
|
||||
const storePath = cfg ? resolveCronJobsStorePathFromConfig(cfg) : resolveCronJobsStorePath();
|
||||
const store = await loadCronJobsStore(storePath);
|
||||
const monitor = store.jobs.find((job) => heartbeatMonitorAgentId(job) === "main");
|
||||
if (!monitor) {
|
||||
throw new Error("expected migrated heartbeat monitor");
|
||||
}
|
||||
return { monitor, storePath };
|
||||
}
|
||||
|
||||
describe("HEARTBEAT.md cron scratch migration", () => {
|
||||
it("previews without mutation, then migrates, archives, and reruns idempotently", async () => {
|
||||
const fixture = await createFixture();
|
||||
const content = `# Operations\n\ntasks:\n - name: inbox\n interval: 1h\n prompt: Check inbox\n`;
|
||||
await fs.writeFile(fixture.heartbeatPath, content, "utf8");
|
||||
|
||||
const findings = await collectHeartbeatScratchMigrationFindings(fixture.cfg);
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/heartbeat-scratch-migration",
|
||||
requirement: "legacy-heartbeat-file",
|
||||
target: "main",
|
||||
}),
|
||||
]);
|
||||
await maybeMigrateHeartbeatFilesToScratch({ cfg: fixture.cfg, shouldRepair: false });
|
||||
await expect(fs.readFile(fixture.heartbeatPath, "utf8")).resolves.toBe(content);
|
||||
|
||||
const migrated = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(migrated.warnings).toEqual([]);
|
||||
expect(migrated.changes).toHaveLength(1);
|
||||
await expect(fs.access(fixture.heartbeatPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const { monitor, storePath } = await loadMonitor();
|
||||
expect(readCronJobScratchState(storePath, monitor.id).scratch).toEqual(
|
||||
expect.objectContaining({ content, revision: 1, sourceSha256: expect.any(String) }),
|
||||
);
|
||||
const archiveDir = path.join(fixture.stateDir, "backups", "heartbeat-migration");
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives).toHaveLength(1);
|
||||
await expect(fs.readFile(path.join(archiveDir, archives[0]!), "utf8")).resolves.toBe(content);
|
||||
|
||||
const rerun = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(rerun).toEqual({ changes: [], warnings: [] });
|
||||
});
|
||||
|
||||
it("leaves a legacy file when operator scratch has different content", async () => {
|
||||
const fixture = await createFixture();
|
||||
await fs.writeFile(fixture.heartbeatPath, "legacy file\n", "utf8");
|
||||
await maybeMigrateHeartbeatFilesToScratch({ cfg: fixture.cfg, shouldRepair: false });
|
||||
const prepared = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(prepared.warnings).toEqual([]);
|
||||
|
||||
// Recreate a retired source after an operator edit: doctor must not overwrite it.
|
||||
const { monitor, storePath } = await loadMonitor();
|
||||
const current = readCronJobScratchState(storePath, monitor.id);
|
||||
writeCronJobScratch({
|
||||
storePath,
|
||||
jobId: monitor.id,
|
||||
content: "operator scratch\n",
|
||||
expectedRevision: current.currentRevision,
|
||||
});
|
||||
await fs.writeFile(fixture.heartbeatPath, "recreated legacy file\n", "utf8");
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings.join("\n")).toContain("already has different cron scratch");
|
||||
await expect(fs.readFile(fixture.heartbeatPath, "utf8")).resolves.toBe(
|
||||
"recreated legacy file\n",
|
||||
);
|
||||
expect(readCronJobScratchState(storePath, monitor.id).scratch?.content).toBe(
|
||||
"operator scratch\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("imports a shared workspace file into every agent monitor before removing it", async () => {
|
||||
const fixture = await createFixture();
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { heartbeat: { every: "30m" } },
|
||||
list: [
|
||||
{ id: "main", workspace: fixture.workspace },
|
||||
{ id: "ops", workspace: fixture.workspace },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await fs.writeFile(fixture.heartbeatPath, "shared checklist\n", "utf8");
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({ cfg, shouldRepair: true });
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toHaveLength(2);
|
||||
await expect(fs.access(fixture.heartbeatPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const storePath = resolveCronJobsStorePath();
|
||||
const store = await loadCronJobsStore(storePath);
|
||||
for (const agentId of ["main", "ops"]) {
|
||||
const monitor = store.jobs.find((job) => heartbeatMonitorAgentId(job) === agentId);
|
||||
expect(monitor, agentId).toBeDefined();
|
||||
expect(readCronJobScratchState(storePath, monitor!.id).scratch?.content, agentId).toBe(
|
||||
"shared checklist\n",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects a configured cron store partition", async () => {
|
||||
const fixture = await createFixture();
|
||||
const customStore = path.join(fixture.root, "custom-cron", "jobs.json");
|
||||
const cfg = { ...fixture.cfg, cron: { store: customStore } } as unknown as OpenClawConfig;
|
||||
await fs.writeFile(fixture.heartbeatPath, "custom store scratch\n", "utf8");
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({ cfg, shouldRepair: true });
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
const { monitor, storePath } = await loadMonitor(cfg);
|
||||
expect(storePath).toBe(path.resolve(customStore));
|
||||
expect(readCronJobScratchState(storePath, monitor.id).scratch?.content).toBe(
|
||||
"custom store scratch\n",
|
||||
);
|
||||
expect((await loadCronJobsStore(resolveCronJobsStorePath())).jobs).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not resurrect a legacy file after scratch was explicitly unset", async () => {
|
||||
const fixture = await createFixture();
|
||||
await fs.writeFile(fixture.heartbeatPath, "initial\n", "utf8");
|
||||
await maybeMigrateHeartbeatFilesToScratch({ cfg: fixture.cfg, shouldRepair: true });
|
||||
const { monitor, storePath } = await loadMonitor();
|
||||
const state = readCronJobScratchState(storePath, monitor.id);
|
||||
const unset = writeCronJobScratch({
|
||||
storePath,
|
||||
jobId: monitor.id,
|
||||
content: null,
|
||||
expectedRevision: state.currentRevision,
|
||||
});
|
||||
expect(unset.ok).toBe(true);
|
||||
await fs.writeFile(fixture.heartbeatPath, "recreated\n", "utf8");
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings.join("\n")).toContain("scratch was explicitly unset");
|
||||
await expect(fs.readFile(fixture.heartbeatPath, "utf8")).resolves.toBe("recreated\n");
|
||||
expect(readCronJobScratchState(storePath, monitor.id).scratch).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a concurrent file replacement acquired by the atomic claim", async () => {
|
||||
const fixture = await createFixture();
|
||||
await fs.writeFile(fixture.heartbeatPath, "planned content\n", "utf8");
|
||||
const rename = fs.rename.bind(fs);
|
||||
vi.spyOn(fs, "rename").mockImplementationOnce(async (from, to) => {
|
||||
await fs.writeFile(String(from), "concurrent replacement\n", "utf8");
|
||||
await rename(from, to);
|
||||
});
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(result.warnings.join("\n")).toContain("changed before the migration claim");
|
||||
await expect(fs.readFile(fixture.heartbeatPath, "utf8")).resolves.toBe(
|
||||
"concurrent replacement\n",
|
||||
);
|
||||
// Nothing may be committed for a failed claim: scratch would otherwise
|
||||
// shadow the restored replacement file on the next heartbeat.
|
||||
expect(result.changes).toEqual([]);
|
||||
const { monitor, storePath } = await loadMonitor();
|
||||
expect(readCronJobScratchState(storePath, monitor.id)).toEqual({ currentRevision: 0 });
|
||||
});
|
||||
|
||||
it("never clobbers a recreated file when restoring a failed claim", async () => {
|
||||
const fixture = await createFixture();
|
||||
await fs.writeFile(fixture.heartbeatPath, "claimed original\n", "utf8");
|
||||
const realpath = fs.realpath.bind(fs);
|
||||
// Fail the claim after the rename, and recreate the destination before the
|
||||
// restore runs — the classic editor atomic-save race.
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async (target) => {
|
||||
if (String(target).includes(".doctor-importing-")) {
|
||||
await fs.writeFile(fixture.heartbeatPath, "editor rewrite\n", "utf8");
|
||||
throw new Error("simulated claim verification failure");
|
||||
}
|
||||
return await realpath(target);
|
||||
});
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
await expect(fs.readFile(fixture.heartbeatPath, "utf8")).resolves.toBe("editor rewrite\n");
|
||||
const workspaceEntries = await fs.readdir(fixture.workspace);
|
||||
const conflict = workspaceEntries.find((entry) => entry.includes(".conflict-"));
|
||||
expect(conflict, workspaceEntries.join(",")).toBeDefined();
|
||||
await expect(fs.readFile(path.join(fixture.workspace, conflict!), "utf8")).resolves.toBe(
|
||||
"claimed original\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers an interrupted migration claim on the next doctor --fix", async () => {
|
||||
const fixture = await createFixture();
|
||||
const content = "interrupted checklist\n";
|
||||
// Simulate a crash after the claim rename: only the claim file exists.
|
||||
// Use a provably dead PID so recovery does not treat it as an active run.
|
||||
const deadPid = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||
await fs.writeFile(
|
||||
`${fixture.heartbeatPath}.doctor-importing-${deadPid}-deadbeefdead`,
|
||||
content,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const findings = await collectHeartbeatScratchMigrationFindings(fixture.cfg);
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({ requirement: "heartbeat-file-migration-blocked" }),
|
||||
]);
|
||||
expect(findings[0]!.message).toContain("interrupted migration claim");
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toHaveLength(1);
|
||||
const { monitor, storePath } = await loadMonitor();
|
||||
expect(readCronJobScratchState(storePath, monitor.id).scratch?.content).toBe(content);
|
||||
const workspaceEntries = await fs.readdir(fixture.workspace);
|
||||
expect(workspaceEntries.filter((entry) => entry.includes(".doctor-importing-"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to steal a claim held by a live doctor process", async () => {
|
||||
const fixture = await createFixture();
|
||||
// This test's own PID is trivially alive and not ours-by-name.
|
||||
const claimPath = `${fixture.heartbeatPath}.doctor-importing-1-abcdefabcdef`;
|
||||
await fs.writeFile(claimPath, "in-flight migration\n", "utf8");
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings.join("\n")).toContain("held by running process 1");
|
||||
await expect(fs.readFile(claimPath, "utf8")).resolves.toBe("in-flight migration\n");
|
||||
});
|
||||
|
||||
it("migrates a contained symlinked HEARTBEAT.md and removes the link", async () => {
|
||||
const fixture = await createFixture();
|
||||
const targetPath = path.join(fixture.workspace, "real-heartbeat.md");
|
||||
await fs.writeFile(targetPath, "linked checklist\n", "utf8");
|
||||
await fs.symlink("real-heartbeat.md", fixture.heartbeatPath);
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toHaveLength(1);
|
||||
const { monitor, storePath } = await loadMonitor();
|
||||
expect(readCronJobScratchState(storePath, monitor.id).scratch?.content).toBe(
|
||||
"linked checklist\n",
|
||||
);
|
||||
// The symlink is removed; the contained target file itself is untouched.
|
||||
await expect(fs.lstat(fixture.heartbeatPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("linked checklist\n");
|
||||
});
|
||||
|
||||
it("rejects external symlink targets without importing or removing them", async () => {
|
||||
const fixture = await createFixture();
|
||||
const external = path.join(fixture.root, "outside.md");
|
||||
await fs.writeFile(external, "outside\n", "utf8");
|
||||
await fs.symlink(external, fixture.heartbeatPath);
|
||||
|
||||
const result = await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: fixture.cfg,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings.join("\n")).toContain("escapes the agent workspace");
|
||||
await expect(fs.lstat(fixture.heartbeatPath)).resolves.toMatchObject({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
/** Doctor-owned migration from workspace HEARTBEAT.md files into cron job scratch. */
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveHeartbeatMonitorSpecs } from "../cron/heartbeat-monitor.js";
|
||||
import { CRON_JOB_SCRATCH_MAX_BYTES } from "../cron/scratch-contract.js";
|
||||
import {
|
||||
deleteCronJobScratch,
|
||||
hashCronScratchSource,
|
||||
readCronJobScratchState,
|
||||
writeCronJobScratch,
|
||||
} from "../cron/scratch-store.js";
|
||||
import { CronService } from "../cron/service.js";
|
||||
import { resolveCronJobsStorePathFromConfig } from "../cron/store.js";
|
||||
import type { CronJob } from "../cron/types.js";
|
||||
import type { HealthFinding } from "../flows/health-checks.js";
|
||||
import { resolveHeartbeatAgents } from "../infra/heartbeat-runner.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { readRegularFile } from "../infra/regular-file.js";
|
||||
import { escapeRegExp } from "../shared/regexp.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
const HEARTBEAT_SCRATCH_MIGRATION_CHECK_ID = "core/doctor/heartbeat-scratch-migration";
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
type HeartbeatScratchMigrationResult = {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type HeartbeatSource = {
|
||||
path: string;
|
||||
/** Canonical parent directory + basename: the identity of the removable entry. */
|
||||
entryKey: string;
|
||||
content: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function readHeartbeatSource(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
options?: { recoverClaims?: boolean },
|
||||
): Promise<HeartbeatSource | undefined> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = await fs.lstat(heartbeatPath);
|
||||
// A claim sibling next to an existing canonical file means an interrupted
|
||||
// migration raced a recreation. Neither copy is provably authoritative, so
|
||||
// stop instead of migrating one and silently resurrecting the other later.
|
||||
const orphanClaim = await findStaleHeartbeatClaim(heartbeatPath);
|
||||
if (orphanClaim) {
|
||||
throw new Error(
|
||||
`both ${heartbeatPath} and an interrupted migration claim at ${orphanClaim} exist; reconcile them manually before rerunning doctor`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
// Crash recovery: a killed run can leave the only copy at a claim path
|
||||
// after the rename but before scratch release. Surface it here so both
|
||||
// findings and repair see the interrupted migration instead of "no file".
|
||||
const staleClaim = await findStaleHeartbeatClaim(heartbeatPath);
|
||||
if (!staleClaim) {
|
||||
return undefined;
|
||||
}
|
||||
if (!options?.recoverClaims) {
|
||||
throw new Error(
|
||||
`an interrupted migration claim exists at ${staleClaim}; run openclaw doctor --fix to restore it`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
await restoreClaimNoClobber(staleClaim, heartbeatPath);
|
||||
sourceStat = await fs.lstat(heartbeatPath);
|
||||
}
|
||||
if (!sourceStat.isFile() && !sourceStat.isSymbolicLink()) {
|
||||
throw new Error("HEARTBEAT.md must be a regular file or contained symlink");
|
||||
}
|
||||
if (sourceStat.isFile() && sourceStat.nlink > 1) {
|
||||
throw new Error("HEARTBEAT.md has multiple hard links; refusing automatic removal");
|
||||
}
|
||||
|
||||
const workspaceRealPath = await fs.realpath(workspaceDir);
|
||||
const sourceRealPath = await fs.realpath(heartbeatPath);
|
||||
if (sourceRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, sourceRealPath)) {
|
||||
throw new Error("HEARTBEAT.md symlink target escapes the agent workspace");
|
||||
}
|
||||
const file = await readRegularFile({
|
||||
filePath: sourceRealPath,
|
||||
maxBytes: CRON_JOB_SCRATCH_MAX_BYTES,
|
||||
});
|
||||
let content: string;
|
||||
try {
|
||||
content = utf8Decoder.decode(file.buffer);
|
||||
} catch {
|
||||
throw new Error("HEARTBEAT.md is not valid UTF-8");
|
||||
}
|
||||
return {
|
||||
path: heartbeatPath,
|
||||
entryKey: path.join(workspaceRealPath, DEFAULT_HEARTBEAT_FILENAME),
|
||||
content,
|
||||
sha256: hashCronScratchSource(content),
|
||||
};
|
||||
}
|
||||
|
||||
function createDoctorCronService(storePath: string, cfg: OpenClawConfig): CronService {
|
||||
const noop = () => {};
|
||||
const log = { debug: noop, info: noop, warn: noop, error: noop };
|
||||
return new CronService({
|
||||
storePath,
|
||||
cronEnabled: false,
|
||||
cronConfig: cfg.cron,
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
log,
|
||||
enqueueSystemEvent: () => false,
|
||||
requestHeartbeat: noop,
|
||||
runIsolatedAgentJob: async () => ({
|
||||
status: "skipped",
|
||||
error: "doctor does not execute cron jobs",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureHeartbeatMonitorJobs(
|
||||
cfg: OpenClawConfig,
|
||||
storePath: string,
|
||||
): Promise<Map<string, CronJob>> {
|
||||
const cron = createDoctorCronService(storePath, cfg);
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const specs = resolveHeartbeatMonitorSpecs(cfg, jobs);
|
||||
const monitors = new Map<string, CronJob>();
|
||||
for (const spec of specs) {
|
||||
const result = await cron.add(spec.input, {
|
||||
enabledExplicit: true,
|
||||
systemOwned: true,
|
||||
matchesExisting: (job) => job.payload.kind === "heartbeat",
|
||||
});
|
||||
const job = "job" in result ? result.job : result;
|
||||
monitors.set(spec.agentId, job);
|
||||
}
|
||||
return monitors;
|
||||
}
|
||||
|
||||
function archivePathForSource(agentId: string, sha256: string, env: NodeJS.ProcessEnv): string {
|
||||
const safeAgentId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||
return path.join(
|
||||
resolveStateDir(env),
|
||||
"backups",
|
||||
"heartbeat-migration",
|
||||
`${safeAgentId}-${sha256}.md`,
|
||||
);
|
||||
}
|
||||
|
||||
type HeartbeatSourceClaim = {
|
||||
claimPath: string;
|
||||
restore(cause: unknown): Promise<void>;
|
||||
release(params: { archivePath: string }): Promise<void>;
|
||||
};
|
||||
|
||||
const HEARTBEAT_CLAIM_INFIX = ".doctor-importing-";
|
||||
const HEARTBEAT_CLAIM_CHANGED_ERROR = "HeartbeatClaimChangedError";
|
||||
|
||||
/** Interrupted-claim sibling for a missing canonical heartbeat path. */
|
||||
async function findStaleHeartbeatClaim(heartbeatPath: string): Promise<string | undefined> {
|
||||
const dir = path.dirname(heartbeatPath);
|
||||
// Match the exact generated claim shape so an unrelated user file that
|
||||
// merely shares the prefix is never consumed by recovery.
|
||||
const claimPattern = new RegExp(
|
||||
`^${escapeRegExp(path.basename(heartbeatPath))}${escapeRegExp(HEARTBEAT_CLAIM_INFIX)}\\d+-[0-9a-f]{12}$`,
|
||||
);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const claims = entries.filter((entry) => claimPattern.test(entry));
|
||||
if (claims.length > 1) {
|
||||
throw new Error(
|
||||
`multiple interrupted migration claims exist for ${heartbeatPath}; remove or restore the stale .doctor-importing-* files manually`,
|
||||
);
|
||||
}
|
||||
const claim = claims[0];
|
||||
if (!claim) {
|
||||
return undefined;
|
||||
}
|
||||
// The claim name embeds the owning PID. A live owner means another doctor
|
||||
// run is mid-migration; stealing its claim could delete both copies.
|
||||
const ownerPid = Number(
|
||||
claim
|
||||
.slice(claim.lastIndexOf(HEARTBEAT_CLAIM_INFIX) + HEARTBEAT_CLAIM_INFIX.length)
|
||||
.split("-")[0],
|
||||
);
|
||||
if (Number.isSafeInteger(ownerPid) && ownerPid !== process.pid && isProcessAlive(ownerPid)) {
|
||||
throw new Error(
|
||||
`a migration claim for ${heartbeatPath} is held by running process ${ownerPid}; wait for that doctor run to finish`,
|
||||
);
|
||||
}
|
||||
return path.join(dir, claim);
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM means the process exists but is not signalable by this user.
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a claim without clobbering: `link` fails with EEXIST when another
|
||||
* process recreated the destination while we held the claim, so both files
|
||||
* survive (the recreation in place, the claimed original at a conflict path).
|
||||
*/
|
||||
async function restoreClaimNoClobber(claimPath: string, destinationPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.link(claimPath, destinationPath);
|
||||
await fs.unlink(claimPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
const conflictPath = `${claimPath}.conflict-${Date.now()}`;
|
||||
await fs.rename(claimPath, conflictPath);
|
||||
throw new Error(
|
||||
`HEARTBEAT.md was recreated during migration; the claimed original is preserved at ${conflictPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the source aside and prove the claimed bytes still match what was read.
|
||||
* The claim happens before any scratch write so a concurrent edit can never
|
||||
* leave stale content committed while the replacement file is restored.
|
||||
*/
|
||||
async function claimHeartbeatSource(source: HeartbeatSource): Promise<HeartbeatSourceClaim> {
|
||||
const claimPath = `${source.path}${HEARTBEAT_CLAIM_INFIX}${process.pid}-${source.sha256.slice(0, 12)}`;
|
||||
await fs.rename(source.path, claimPath);
|
||||
const restore = async (cause: unknown) => {
|
||||
await restoreClaimNoClobber(claimPath, source.path).catch((restoreError: unknown) => {
|
||||
throw restoreError instanceof Error && restoreError.message.includes("preserved at")
|
||||
? restoreError
|
||||
: new Error(`HEARTBEAT.md migration claim could not be restored from ${claimPath}`, {
|
||||
cause: cause ?? restoreError,
|
||||
});
|
||||
});
|
||||
};
|
||||
try {
|
||||
const workspaceRealPath = await fs.realpath(path.dirname(source.path));
|
||||
const claimRealPath = await fs.realpath(claimPath);
|
||||
if (claimRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, claimRealPath)) {
|
||||
throw new Error("claimed HEARTBEAT.md target escapes the agent workspace");
|
||||
}
|
||||
const claimed = await readRegularFile({
|
||||
filePath: claimRealPath,
|
||||
maxBytes: CRON_JOB_SCRATCH_MAX_BYTES,
|
||||
});
|
||||
const claimedContent = utf8Decoder.decode(claimed.buffer);
|
||||
if (hashCronScratchSource(claimedContent) !== source.sha256) {
|
||||
throw new Error("HEARTBEAT.md changed before the migration claim was acquired");
|
||||
}
|
||||
} catch (error) {
|
||||
await restore(error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
claimPath,
|
||||
restore,
|
||||
release: async ({ archivePath }) => {
|
||||
// Every verification failure here means "do not trust the import":
|
||||
// restore the claim and tag the error so the caller rolls scratch back.
|
||||
const failChanged = async (message: string, cause?: unknown): Promise<never> => {
|
||||
const error = new Error(message, cause !== undefined ? { cause } : undefined);
|
||||
error.name = HEARTBEAT_CLAIM_CHANGED_ERROR;
|
||||
await restore(error).catch(() => undefined);
|
||||
throw error;
|
||||
};
|
||||
// A holder of an already-open descriptor can still mutate the claimed
|
||||
// inode; re-verify the bytes so release never deletes an unseen edit.
|
||||
// The claim may itself be a contained symlink (renaming a symlink keeps
|
||||
// it a symlink), so resolve and containment-check it like the claim did.
|
||||
let finalContent: string;
|
||||
try {
|
||||
const workspaceRealPath = await fs.realpath(path.dirname(source.path));
|
||||
const claimRealPath = await fs.realpath(claimPath);
|
||||
if (
|
||||
claimRealPath !== workspaceRealPath &&
|
||||
!isPathInside(workspaceRealPath, claimRealPath)
|
||||
) {
|
||||
throw new Error("claimed HEARTBEAT.md target escapes the agent workspace");
|
||||
}
|
||||
const finalBytes = await readRegularFile({
|
||||
filePath: claimRealPath,
|
||||
maxBytes: CRON_JOB_SCRATCH_MAX_BYTES,
|
||||
});
|
||||
finalContent = utf8Decoder.decode(finalBytes.buffer);
|
||||
} catch (error) {
|
||||
await failChanged("claimed HEARTBEAT.md could not be re-verified before removal", error);
|
||||
throw error;
|
||||
}
|
||||
if (hashCronScratchSource(finalContent) !== source.sha256) {
|
||||
await failChanged("HEARTBEAT.md changed while the migration claim was held");
|
||||
}
|
||||
// An editor atomic-save can recreate the original path while the claim
|
||||
// is held. That recreation is the newest instruction set; treat it like
|
||||
// a changed claim so the import rolls back instead of shadowing it.
|
||||
let recreated: boolean;
|
||||
try {
|
||||
await fs.lstat(source.path);
|
||||
recreated = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
await failChanged(
|
||||
"could not verify the original HEARTBEAT.md path before removal",
|
||||
error,
|
||||
);
|
||||
}
|
||||
recreated = false;
|
||||
}
|
||||
if (recreated) {
|
||||
await failChanged("HEARTBEAT.md was recreated while the migration claim was held");
|
||||
}
|
||||
// Retire the claim by moving the inode into the archive instead of
|
||||
// unlinking it: a writer holding an open descriptor that lands a write
|
||||
// after the hash check above still writes into the preserved archive
|
||||
// file, never into a deleted inode.
|
||||
const claimStat = await fs.lstat(claimPath);
|
||||
if (claimStat.isSymbolicLink()) {
|
||||
// The removable entry is the symlink itself; its target file stays in
|
||||
// the workspace, so no open-descriptor write can be lost here.
|
||||
await fs.unlink(claimPath);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.rename(claimPath, archivePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
// Cross-device archive: the pre-written archive copy already holds the
|
||||
// verified bytes. Accepted tradeoff: the unlink below reopens the
|
||||
// microsecond open-descriptor window only when workspace and state dir
|
||||
// sit on different filesystems.
|
||||
await fs.unlink(claimPath);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function archiveSource(params: {
|
||||
agentId: string;
|
||||
source: HeartbeatSource;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const archivePath = archivePathForSource(params.agentId, params.source.sha256, params.env);
|
||||
await fs.mkdir(path.dirname(archivePath), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await fs.writeFile(archivePath, params.source.content, { flag: "wx", mode: 0o600 });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
const existing = await fs.readFile(archivePath, "utf8");
|
||||
if (hashCronScratchSource(existing) !== params.source.sha256) {
|
||||
throw new Error(`heartbeat migration archive collision at ${archivePath}`, { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrationFinding(params: {
|
||||
agentId: string;
|
||||
path: string;
|
||||
requirement: string;
|
||||
message: string;
|
||||
severity?: HealthFinding["severity"];
|
||||
}): HealthFinding {
|
||||
return {
|
||||
checkId: HEARTBEAT_SCRATCH_MIGRATION_CHECK_ID,
|
||||
severity: params.severity ?? "warning",
|
||||
message: params.message,
|
||||
path: params.path,
|
||||
target: params.agentId,
|
||||
requirement: params.requirement,
|
||||
fixHint: `Run ${formatCliCommand("openclaw doctor --fix")} to migrate HEARTBEAT.md into cron scratch.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reports remaining workspace heartbeat files without changing them. */
|
||||
export async function collectHeartbeatScratchMigrationFindings(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
const findings: HealthFinding[] = [];
|
||||
for (const agent of resolveHeartbeatAgents(cfg)) {
|
||||
const heartbeatPath = path.join(
|
||||
resolveAgentWorkspaceDir(cfg, agent.agentId),
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
);
|
||||
try {
|
||||
const source = await readHeartbeatSource(cfg, agent.agentId);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
findings.push(
|
||||
migrationFinding({
|
||||
agentId: agent.agentId,
|
||||
path: heartbeatPath,
|
||||
requirement: "legacy-heartbeat-file",
|
||||
message: `Agent "${agent.agentId}" still stores heartbeat instructions in HEARTBEAT.md.`,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
findings.push(
|
||||
migrationFinding({
|
||||
agentId: agent.agentId,
|
||||
path: heartbeatPath,
|
||||
requirement: "heartbeat-file-migration-blocked",
|
||||
severity: "error",
|
||||
message: `Agent "${agent.agentId}" HEARTBEAT.md cannot be migrated: ${errorMessage(error)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Migrates each enrolled agent's heartbeat file into its stable monitor job. */
|
||||
export async function maybeMigrateHeartbeatFilesToScratch(params: {
|
||||
cfg: OpenClawConfig;
|
||||
shouldRepair: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<HeartbeatScratchMigrationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const storePath = resolveCronJobsStorePathFromConfig(params.cfg, env);
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (!params.shouldRepair) {
|
||||
for (const agent of resolveHeartbeatAgents(params.cfg)) {
|
||||
try {
|
||||
const source = await readHeartbeatSource(params.cfg, agent.agentId);
|
||||
if (source) {
|
||||
note(
|
||||
`${shortenHomePath(source.path)} will migrate into scratch for Heartbeat (${agent.agentId}).`,
|
||||
"Heartbeat migration preview",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Agent "${agent.agentId}" HEARTBEAT.md cannot be migrated: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
note(warnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
let monitors: Map<string, CronJob>;
|
||||
try {
|
||||
monitors = await ensureHeartbeatMonitorJobs(params.cfg, storePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [`Could not prepare heartbeat monitor jobs: ${errorMessage(error)}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Agents can share one workspace file. Group monitors by source path and
|
||||
// import into every monitor before the file is archived and removed once, so
|
||||
// the first agent's cleanup cannot starve its siblings.
|
||||
const groups = new Map<string, { source: HeartbeatSource; agents: [string, CronJob][] }>();
|
||||
for (const [agentId, monitor] of monitors) {
|
||||
let source: HeartbeatSource | undefined;
|
||||
try {
|
||||
source = await readHeartbeatSource(params.cfg, agentId, { recoverClaims: true });
|
||||
} catch (error) {
|
||||
warnings.push(`Agent "${agentId}" HEARTBEAT.md was not migrated: ${errorMessage(error)}`);
|
||||
continue;
|
||||
}
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
// Group by the directory entry being removed (canonical parent directory +
|
||||
// basename), not its resolved file target: two distinct symlinks pointing
|
||||
// at one shared file are each claimed and removed, while agents reaching
|
||||
// the same workspace through path aliases dedupe onto one entry.
|
||||
const group = groups.get(source.entryKey) ?? { source, agents: [] };
|
||||
group.agents.push([agentId, monitor]);
|
||||
groups.set(source.entryKey, group);
|
||||
}
|
||||
|
||||
for (const { source, agents } of groups.values()) {
|
||||
// Precondition pass first: operator-owned scratch (different content or an
|
||||
// explicit unset tombstone) blocks the whole group before the file is
|
||||
// touched, so nothing is claimed or committed for a source that must stay.
|
||||
// The revision seen here is also the CAS token for the later write, so a
|
||||
// concurrent edit in between surfaces as a conflict, never an overwrite.
|
||||
let blocked = false;
|
||||
const plannedRevisionByJobId = new Map<string, number>();
|
||||
for (const [agentId, monitor] of agents) {
|
||||
const state = readCronJobScratchState(storePath, monitor.id, { env });
|
||||
const current = state.scratch;
|
||||
plannedRevisionByJobId.set(monitor.id, state.currentRevision);
|
||||
if (state.currentRevision > 0 && !current) {
|
||||
warnings.push(
|
||||
`Agent "${agentId}" scratch was explicitly unset; ${shortenHomePath(source.path)} was left unchanged.`,
|
||||
);
|
||||
blocked = true;
|
||||
} else if (
|
||||
current &&
|
||||
current.content !== source.content &&
|
||||
current.sourceSha256 !== source.sha256
|
||||
) {
|
||||
warnings.push(
|
||||
`Agent "${agentId}" already has different cron scratch; ${shortenHomePath(source.path)} was left unchanged.`,
|
||||
);
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
if (blocked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Archive before the claim rename: if doctor dies mid-claim, the content is
|
||||
// already durable under the state backups instead of only at a hidden
|
||||
// .doctor-importing-* path nothing rescans.
|
||||
try {
|
||||
await archiveSource({ agentId: agents[0]![0], source, env });
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`${shortenHomePath(source.path)} was not migrated: ${errorMessage(error)}. Rerun doctor to retry safely.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claim before committing: once the file is renamed aside and hash-verified,
|
||||
// no concurrent editor can change the bytes that reach scratch, and a claim
|
||||
// failure restores the file with nothing committed.
|
||||
let claim: HeartbeatSourceClaim;
|
||||
try {
|
||||
claim = await claimHeartbeatSource(source);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`${shortenHomePath(source.path)} was not migrated: ${errorMessage(error)}. Rerun doctor to retry safely.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let importedAll = true;
|
||||
const groupChanges: string[] = [];
|
||||
const committedThisRun: Array<{
|
||||
agentId: string;
|
||||
monitor: CronJob;
|
||||
previous: ReturnType<typeof readCronJobScratchState>["scratch"];
|
||||
newRevision: number;
|
||||
}> = [];
|
||||
for (const [agentId, monitor] of agents) {
|
||||
try {
|
||||
const state = readCronJobScratchState(storePath, monitor.id, { env });
|
||||
if (state.scratch?.sourceSha256 !== source.sha256) {
|
||||
const write = writeCronJobScratch({
|
||||
storePath,
|
||||
jobId: monitor.id,
|
||||
content: source.content,
|
||||
expectedRevision: plannedRevisionByJobId.get(monitor.id) ?? state.currentRevision,
|
||||
sourceSha256: source.sha256,
|
||||
options: { env },
|
||||
});
|
||||
if (!write.ok) {
|
||||
throw new Error("scratch changed during migration");
|
||||
}
|
||||
committedThisRun.push({
|
||||
agentId,
|
||||
monitor,
|
||||
previous: state.scratch,
|
||||
newRevision: write.currentRevision,
|
||||
});
|
||||
}
|
||||
const verified = readCronJobScratchState(storePath, monitor.id, { env }).scratch;
|
||||
if (
|
||||
!verified ||
|
||||
verified.content !== source.content ||
|
||||
verified.sourceSha256 !== source.sha256
|
||||
) {
|
||||
throw new Error("scratch verification failed after write");
|
||||
}
|
||||
groupChanges.push(
|
||||
`Migrated ${shortenHomePath(source.path)} into cron scratch for ${monitor.displayName ?? monitor.name}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Agent "${agentId}" scratch was not finalized: ${errorMessage(error)}. Rerun doctor to retry safely.`,
|
||||
);
|
||||
importedAll = false;
|
||||
}
|
||||
}
|
||||
// The restored legacy file is authoritative again after any rollback, so
|
||||
// this run's scratch imports must revert too — otherwise those agents keep
|
||||
// serving the imported copy and ignore later edits to the restored file.
|
||||
// A monitor that had no row before must return to no-row (not a tombstone),
|
||||
// or the runner's legacy fallback and future migrations stay suppressed.
|
||||
const rollbackCommitted = () => {
|
||||
for (const commit of committedThisRun.toReversed()) {
|
||||
if (!commit.previous) {
|
||||
// Revision-guarded atomic delete restores the pre-migration no-row
|
||||
// state so the runner's legacy fallback stays available. Accepted
|
||||
// tradeoff: this resets the revision counter to 0, so a writer still
|
||||
// holding a pre-migration expectedRevision:0 token could CAS through
|
||||
// after the rollback; that requires a third concurrent writer racing
|
||||
// doctor and is preferred over permanently disabling the fallback.
|
||||
const deleted = deleteCronJobScratch(
|
||||
storePath,
|
||||
commit.monitor.id,
|
||||
{ env },
|
||||
{
|
||||
expectedRevision: commit.newRevision,
|
||||
},
|
||||
);
|
||||
if (!deleted) {
|
||||
warnings.push(
|
||||
`Agent "${commit.agentId}" scratch changed before the migration rollback; leaving current scratch in place.`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const revert = writeCronJobScratch({
|
||||
storePath,
|
||||
jobId: commit.monitor.id,
|
||||
content: commit.previous.content,
|
||||
expectedRevision: commit.newRevision,
|
||||
sourceSha256: commit.previous.sourceSha256,
|
||||
options: { env },
|
||||
});
|
||||
if (!revert.ok) {
|
||||
warnings.push(
|
||||
`Agent "${commit.agentId}" scratch changed before the migration rollback; leaving current scratch in place.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!importedAll) {
|
||||
rollbackCommitted();
|
||||
try {
|
||||
await claim.restore(undefined);
|
||||
} catch (error) {
|
||||
warnings.push(errorMessage(error));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// release() re-verifies the claimed bytes; when they changed it restores
|
||||
// the newer file itself and reports HeartbeatClaimChangedError.
|
||||
await claim.release({
|
||||
archivePath: archivePathForSource(agents[0]![0], source.sha256, env),
|
||||
});
|
||||
changes.push(...groupChanges);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === HEARTBEAT_CLAIM_CHANGED_ERROR) {
|
||||
// The changed file is authoritative; committed scratch must not shadow it.
|
||||
rollbackCommitted();
|
||||
warnings.push(
|
||||
`${shortenHomePath(source.path)} was not migrated: ${errorMessage(error)}. Rerun doctor to retry safely.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
changes.push(...groupChanges);
|
||||
warnings.push(
|
||||
`${shortenHomePath(source.path)} was migrated but not removed: ${errorMessage(error)}. Rerun doctor to retry safely.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
note(changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
note(warnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
@@ -153,6 +153,11 @@ vi.mock("./doctor-heartbeat-template-repair.js", () => ({
|
||||
maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./doctor-heartbeat-scratch-migration.js", () => ({
|
||||
collectHeartbeatScratchMigrationFindings: vi.fn().mockResolvedValue([]),
|
||||
maybeMigrateHeartbeatFilesToScratch: vi.fn().mockResolvedValue({ changes: [], warnings: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-openai-chatgpt-oauth-tls.js", () => ({
|
||||
noteOpenAIOAuthTlsPrerequisites: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
@@ -156,7 +156,7 @@ export const MODEL_FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.workspace":
|
||||
"Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.",
|
||||
"agents.defaults.skipOptionalBootstrapFiles":
|
||||
"Optional bootstrap files that should not be created in agent workspaces. Valid values: SOUL.md, USER.md, HEARTBEAT.md, IDENTITY.md.",
|
||||
"Optional bootstrap files that should not be created in agent workspaces. Valid values: SOUL.md, USER.md, IDENTITY.md (HEARTBEAT.md is accepted but a no-op).",
|
||||
"agents.defaults.contextInjection":
|
||||
'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.',
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
|
||||
@@ -16,7 +16,11 @@ import type {
|
||||
|
||||
/** Workspace bootstrap-file injection policy for agent system prompts. */
|
||||
export type AgentContextInjection = "always" | "continuation-skip" | "never";
|
||||
/** Optional bootstrap files that setup can skip while still creating required agent files. */
|
||||
/**
|
||||
* Optional bootstrap files that setup can skip while still creating required
|
||||
* agent files. "HEARTBEAT.md" stays accepted as legacy config input even
|
||||
* though workspace setup no longer writes it.
|
||||
*/
|
||||
export type OptionalBootstrapFileName = "SOUL.md" | "USER.md" | "HEARTBEAT.md" | "IDENTITY.md";
|
||||
/** Embedded runner behavior contract used by strict-agentic provider flows. */
|
||||
export type EmbeddedAgentExecutionContract = "default" | "strict-agentic";
|
||||
@@ -165,9 +169,9 @@ export type AgentDefaultsConfig = {
|
||||
skipBootstrap?: boolean;
|
||||
/**
|
||||
* List of optional bootstrap filenames to skip writing to the workspace root.
|
||||
* Applies to: SOUL.md, USER.md, HEARTBEAT.md, IDENTITY.md.
|
||||
* Applies to: SOUL.md, USER.md, IDENTITY.md ("HEARTBEAT.md" is accepted but a no-op).
|
||||
* Required workspace setup such as AGENTS.md and TOOLS.md still runs.
|
||||
* Example: ["SOUL.md", "USER.md", "HEARTBEAT.md", "IDENTITY.md"]
|
||||
* Example: ["SOUL.md", "USER.md", "IDENTITY.md"]
|
||||
*/
|
||||
skipOptionalBootstrapFiles?: OptionalBootstrapFileName[];
|
||||
/**
|
||||
@@ -309,20 +313,20 @@ export type AgentDefaultsConfig = {
|
||||
to?: string;
|
||||
/** Optional account id for multi-account channels. */
|
||||
accountId?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */
|
||||
/** Override the heartbeat prompt body (default: "Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */
|
||||
prompt?: string;
|
||||
/** Run timeout in seconds for heartbeat agent turns. Unset uses global timeout or heartbeat cadence capped at 600 seconds. */
|
||||
timeoutSeconds?: number;
|
||||
/**
|
||||
* If true, run heartbeat turns with lightweight bootstrap context.
|
||||
* Lightweight mode keeps only HEARTBEAT.md from workspace bootstrap files.
|
||||
* Lightweight mode skips workspace bootstrap files; monitor scratch is
|
||||
* injected by the heartbeat runner either way.
|
||||
*/
|
||||
lightContext?: boolean;
|
||||
/**
|
||||
* If true, run heartbeat turns in an isolated session with no prior
|
||||
* conversation history. The heartbeat only sees its bootstrap context
|
||||
* (HEARTBEAT.md when lightContext is also enabled). Dramatically reduces
|
||||
* per-heartbeat token cost by avoiding the full session transcript.
|
||||
* conversation history. Dramatically reduces per-heartbeat token cost by
|
||||
* avoiding the full session transcript.
|
||||
*/
|
||||
isolatedSession?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Canonical projection from heartbeat config to system-owned cron monitor jobs. */
|
||||
import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolveHeartbeatAgents,
|
||||
resolveHeartbeatSchedulerSeed,
|
||||
} from "../infra/heartbeat-runner.js";
|
||||
import { resolveHeartbeatPhaseMs } from "../infra/heartbeat-schedule.js";
|
||||
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
|
||||
import type { CronJob, CronJobCreate } from "./types.js";
|
||||
|
||||
const HEARTBEAT_DECLARATION_PREFIX = "heartbeat:";
|
||||
|
||||
function heartbeatMonitorDeclarationKey(agentId: string): string {
|
||||
return `${HEARTBEAT_DECLARATION_PREFIX}${agentId}`;
|
||||
}
|
||||
|
||||
export function heartbeatMonitorAgentId(job: CronJob): string | undefined {
|
||||
const key = job.declarationKey;
|
||||
if (!key?.startsWith(HEARTBEAT_DECLARATION_PREFIX) || job.payload.kind !== "heartbeat") {
|
||||
return undefined;
|
||||
}
|
||||
return key.slice(HEARTBEAT_DECLARATION_PREFIX.length) || undefined;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatMonitorSpecs(
|
||||
cfg: OpenClawConfig,
|
||||
existingJobs: readonly CronJob[],
|
||||
): Array<{ agentId: string; input: CronJobCreate }> {
|
||||
const existingByAgentId = new Map<string, CronJob>();
|
||||
for (const job of existingJobs) {
|
||||
const agentId = heartbeatMonitorAgentId(job);
|
||||
if (agentId) {
|
||||
existingByAgentId.set(agentId, job);
|
||||
}
|
||||
}
|
||||
|
||||
const schedulerSeed = resolveHeartbeatSchedulerSeed();
|
||||
return resolveHeartbeatAgents(cfg).flatMap((agent) => {
|
||||
// Unset config already resolves to the 30m default here, so this is null
|
||||
// only for an explicitly disabled cadence ("0m"/invalid). The fallbacks
|
||||
// below therefore only shape the retained disabled monitor row; removing an
|
||||
// interval override or re-enabling always returns to the resolved config.
|
||||
const configuredIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||
const existing = existingByAgentId.get(agent.agentId);
|
||||
const intervalMs =
|
||||
configuredIntervalMs ??
|
||||
(existing?.schedule.kind === "every" ? existing.schedule.everyMs : undefined) ??
|
||||
resolveHeartbeatIntervalMs(cfg, DEFAULT_HEARTBEAT_EVERY, agent.heartbeat);
|
||||
if (!intervalMs) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
agentId: agent.agentId,
|
||||
input: {
|
||||
declarationKey: heartbeatMonitorDeclarationKey(agent.agentId),
|
||||
displayName: `Heartbeat (${agent.agentId})`,
|
||||
name: `heartbeat-${agent.agentId}`,
|
||||
agentId: agent.agentId,
|
||||
enabled: configuredIntervalMs !== null,
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: intervalMs,
|
||||
anchorMs: resolveHeartbeatPhaseMs({
|
||||
schedulerSeed,
|
||||
agentId: agent.agentId,
|
||||
intervalMs,
|
||||
}),
|
||||
},
|
||||
payload: { kind: "heartbeat" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Publicly stable limits for private per-job scratch content. */
|
||||
export const CRON_JOB_SCRATCH_MAX_BYTES = 256 * 1024;
|
||||
|
||||
export function assertCronJobScratchContent(content: string): void {
|
||||
const sizeBytes = Buffer.byteLength(content, "utf8");
|
||||
if (sizeBytes > CRON_JOB_SCRATCH_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`cron scratch exceeds ${CRON_JOB_SCRATCH_MAX_BYTES} bytes (${sizeBytes} bytes provided)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { CRON_JOB_SCRATCH_MAX_BYTES } from "./scratch-contract.js";
|
||||
import {
|
||||
hashCronScratchSource,
|
||||
readCronJobScratchState,
|
||||
writeCronJobScratch,
|
||||
} from "./scratch-store.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createFixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-scratch-"));
|
||||
tempDirs.push(root);
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") };
|
||||
return {
|
||||
storePath: path.join(root, "cron", "jobs.json"),
|
||||
options: { env },
|
||||
};
|
||||
}
|
||||
|
||||
describe("cron job scratch store", () => {
|
||||
it("distinguishes no row from present-empty content", async () => {
|
||||
const fixture = await createFixture();
|
||||
expect(readCronJobScratchState(fixture.storePath, "job-1", fixture.options)).toEqual({
|
||||
currentRevision: 0,
|
||||
});
|
||||
|
||||
const write = writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content: "",
|
||||
nowMs: 10,
|
||||
});
|
||||
|
||||
expect(write).toEqual({
|
||||
ok: true,
|
||||
currentRevision: 1,
|
||||
scratch: { content: "", revision: 1, updatedAtMs: 10 },
|
||||
});
|
||||
expect(readCronJobScratchState(fixture.storePath, "job-1", fixture.options)).toEqual({
|
||||
currentRevision: 1,
|
||||
scratch: { content: "", revision: 1, updatedAtMs: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("compare-and-swaps revisions and keeps a tombstone across unset", async () => {
|
||||
const fixture = await createFixture();
|
||||
writeCronJobScratch({ ...fixture, jobId: "job-1", content: "first", nowMs: 10 });
|
||||
|
||||
expect(
|
||||
writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content: "stale",
|
||||
expectedRevision: 0,
|
||||
nowMs: 20,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "revision-conflict", currentRevision: 1 });
|
||||
|
||||
expect(
|
||||
writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content: "second",
|
||||
expectedRevision: 1,
|
||||
nowMs: 30,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
currentRevision: 2,
|
||||
scratch: { content: "second", revision: 2, updatedAtMs: 30 },
|
||||
});
|
||||
|
||||
expect(
|
||||
writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content: null,
|
||||
expectedRevision: 2,
|
||||
nowMs: 40,
|
||||
}),
|
||||
).toEqual({ ok: true, currentRevision: 3 });
|
||||
// The tombstone keeps the revision lineage monotonic: a stale writer that
|
||||
// read revision 2 before the unset cannot resurrect old content later.
|
||||
expect(readCronJobScratchState(fixture.storePath, "job-1", fixture.options)).toEqual({
|
||||
currentRevision: 3,
|
||||
});
|
||||
expect(
|
||||
writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content: "resurrected",
|
||||
expectedRevision: 2,
|
||||
nowMs: 50,
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "revision-conflict", currentRevision: 3 });
|
||||
expect(
|
||||
writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content: "recreated",
|
||||
expectedRevision: 3,
|
||||
nowMs: 60,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
currentRevision: 4,
|
||||
scratch: { content: "recreated", revision: 4, updatedAtMs: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it("records migration provenance and clears it on plain rewrites", async () => {
|
||||
const fixture = await createFixture();
|
||||
const content = "# Monitor\n\nCheck mail.\n";
|
||||
const sourceSha256 = hashCronScratchSource(content);
|
||||
|
||||
writeCronJobScratch({
|
||||
...fixture,
|
||||
jobId: "job-1",
|
||||
content,
|
||||
sourceSha256,
|
||||
nowMs: 10,
|
||||
});
|
||||
|
||||
expect(readCronJobScratchState(fixture.storePath, "job-1", fixture.options).scratch).toEqual({
|
||||
content,
|
||||
revision: 1,
|
||||
sourceSha256,
|
||||
updatedAtMs: 10,
|
||||
});
|
||||
|
||||
writeCronJobScratch({ ...fixture, jobId: "job-1", content: "rewritten", nowMs: 20 });
|
||||
expect(readCronJobScratchState(fixture.storePath, "job-1", fixture.options).scratch).toEqual({
|
||||
content: "rewritten",
|
||||
revision: 2,
|
||||
updatedAtMs: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects content above the fixed UTF-8 byte limit", async () => {
|
||||
const fixture = await createFixture();
|
||||
const content = "é".repeat(CRON_JOB_SCRATCH_MAX_BYTES / 2 + 1);
|
||||
|
||||
expect(() => writeCronJobScratch({ ...fixture, jobId: "job-1", content })).toThrow(
|
||||
`cron scratch exceeds ${CRON_JOB_SCRATCH_MAX_BYTES} bytes`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/** Database-backed per-job scratch storage, kept outside public cron job state. */
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { executeSqliteQuerySync } from "../infra/kysely-sync.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { assertCronJobScratchContent } from "./scratch-contract.js";
|
||||
import { cronStoreKey } from "./store/key.js";
|
||||
import { getCronStoreKysely } from "./store/schema.js";
|
||||
|
||||
type CronJobScratch = {
|
||||
content: string;
|
||||
revision: number;
|
||||
sourceSha256?: string;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Present scratch content plus the persisted revision. An unset scratch keeps a
|
||||
* tombstone row so `currentRevision` stays monotonic across unset/recreate and
|
||||
* stale compare-and-swap writers cannot resurrect old content.
|
||||
*/
|
||||
export type CronJobScratchState = {
|
||||
currentRevision: number;
|
||||
scratch?: CronJobScratch;
|
||||
};
|
||||
|
||||
export type CronJobScratchWriteResult =
|
||||
| { ok: true; currentRevision: number; scratch?: CronJobScratch }
|
||||
| { ok: false; reason: "revision-conflict"; currentRevision: number };
|
||||
|
||||
function rowToState(row: {
|
||||
content: string | null;
|
||||
revision: number;
|
||||
source_sha256: string | null;
|
||||
updated_at_ms: number;
|
||||
}): CronJobScratchState {
|
||||
if (row.content === null) {
|
||||
return { currentRevision: row.revision };
|
||||
}
|
||||
return {
|
||||
currentRevision: row.revision,
|
||||
scratch: {
|
||||
content: row.content,
|
||||
revision: row.revision,
|
||||
...(row.source_sha256 ? { sourceSha256: row.source_sha256 } : {}),
|
||||
updatedAtMs: row.updated_at_ms,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readScratchStateFromDatabase(
|
||||
db: DatabaseSync,
|
||||
storeKey: string,
|
||||
jobId: string,
|
||||
): CronJobScratchState {
|
||||
const cronDb = getCronStoreKysely(db);
|
||||
const row = executeSqliteQuerySync(
|
||||
db,
|
||||
cronDb
|
||||
.selectFrom("cron_job_scratch")
|
||||
.select(["content", "revision", "source_sha256", "updated_at_ms"])
|
||||
.where("store_key", "=", storeKey)
|
||||
.where("job_id", "=", jobId),
|
||||
).rows[0];
|
||||
return row ? rowToState(row) : { currentRevision: 0 };
|
||||
}
|
||||
|
||||
/** Reads one job's scratch state without exposing it through cron list/history surfaces. */
|
||||
export function readCronJobScratchState(
|
||||
storePath: string,
|
||||
jobId: string,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): CronJobScratchState {
|
||||
const { db } = openOpenClawStateDatabase(options);
|
||||
return readScratchStateFromDatabase(db, cronStoreKey(storePath), jobId);
|
||||
}
|
||||
|
||||
/** Resolves the current heartbeat monitor and its scratch with one narrow SQLite query. */
|
||||
export function readHeartbeatMonitorScratch(
|
||||
storePath: string,
|
||||
agentId: string,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): { jobId: string; state: CronJobScratchState } | undefined {
|
||||
const { db } = openOpenClawStateDatabase(options);
|
||||
const storeKey = cronStoreKey(storePath);
|
||||
const cronDb = getCronStoreKysely(db);
|
||||
const row = executeSqliteQuerySync(
|
||||
db,
|
||||
cronDb
|
||||
.selectFrom("cron_jobs")
|
||||
.leftJoin("cron_job_scratch", (join) =>
|
||||
join
|
||||
.onRef("cron_job_scratch.store_key", "=", "cron_jobs.store_key")
|
||||
.onRef("cron_job_scratch.job_id", "=", "cron_jobs.job_id"),
|
||||
)
|
||||
.select([
|
||||
"cron_jobs.job_id as job_id",
|
||||
"cron_job_scratch.content as content",
|
||||
"cron_job_scratch.revision as revision",
|
||||
"cron_job_scratch.source_sha256 as source_sha256",
|
||||
"cron_job_scratch.updated_at_ms as updated_at_ms",
|
||||
])
|
||||
.where("cron_jobs.store_key", "=", storeKey)
|
||||
.where("cron_jobs.declaration_key", "=", `heartbeat:${agentId}`)
|
||||
.where("cron_jobs.payload_kind", "=", "heartbeat"),
|
||||
).rows[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
if (row.revision === null || row.updated_at_ms === null) {
|
||||
return { jobId: row.job_id, state: { currentRevision: 0 } };
|
||||
}
|
||||
return {
|
||||
jobId: row.job_id,
|
||||
state: rowToState({
|
||||
content: row.content,
|
||||
revision: row.revision,
|
||||
source_sha256: row.source_sha256,
|
||||
updated_at_ms: row.updated_at_ms,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes, clears, or compare-and-swaps one scratch row. */
|
||||
export function writeCronJobScratch(params: {
|
||||
storePath: string;
|
||||
jobId: string;
|
||||
content: string | null;
|
||||
expectedRevision?: number;
|
||||
sourceSha256?: string;
|
||||
nowMs?: number;
|
||||
options?: OpenClawStateDatabaseOptions;
|
||||
}): CronJobScratchWriteResult {
|
||||
if (params.content !== null) {
|
||||
assertCronJobScratchContent(params.content);
|
||||
}
|
||||
const storeKey = cronStoreKey(params.storePath);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const { currentRevision } = readScratchStateFromDatabase(db, storeKey, params.jobId);
|
||||
if (params.expectedRevision !== undefined && params.expectedRevision !== currentRevision) {
|
||||
return { ok: false, reason: "revision-conflict", currentRevision } as const;
|
||||
}
|
||||
const cronDb = getCronStoreKysely(db);
|
||||
if (params.content === null && currentRevision === 0) {
|
||||
return { ok: true, currentRevision } as const;
|
||||
}
|
||||
const revision = currentRevision + 1;
|
||||
const sourceSha256 = params.content !== null ? params.sourceSha256?.trim() : undefined;
|
||||
// Full-row replace keeps semantics simple: a write without provenance also
|
||||
// clears a stale migration sha, and an unset leaves a revision tombstone.
|
||||
if (currentRevision > 0) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
cronDb
|
||||
.deleteFrom("cron_job_scratch")
|
||||
.where("store_key", "=", storeKey)
|
||||
.where("job_id", "=", params.jobId),
|
||||
);
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
cronDb.insertInto("cron_job_scratch").values({
|
||||
store_key: storeKey,
|
||||
job_id: params.jobId,
|
||||
content: params.content,
|
||||
revision,
|
||||
...(sourceSha256 ? { source_sha256: sourceSha256 } : {}),
|
||||
updated_at_ms: nowMs,
|
||||
}),
|
||||
);
|
||||
if (params.content === null) {
|
||||
return { ok: true, currentRevision: revision } as const;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
currentRevision: revision,
|
||||
scratch: {
|
||||
content: params.content,
|
||||
revision,
|
||||
...(sourceSha256 ? { sourceSha256 } : {}),
|
||||
updatedAtMs: nowMs,
|
||||
},
|
||||
} as const;
|
||||
},
|
||||
params.options,
|
||||
{ operationLabel: "cron.scratch.write" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes scratch when its owning job is removed, or — with expectedRevision —
|
||||
* atomically reverts a migration write back to the no-row state. Orphans remain
|
||||
* harmless on partial failure. Returns false when the guarded revision moved.
|
||||
*/
|
||||
export function deleteCronJobScratch(
|
||||
storePath: string,
|
||||
jobId: string,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
guard?: { expectedRevision: number },
|
||||
): boolean {
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const storeKey = cronStoreKey(storePath);
|
||||
if (guard) {
|
||||
const { currentRevision } = readScratchStateFromDatabase(db, storeKey, jobId);
|
||||
if (currentRevision !== guard.expectedRevision) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const cronDb = getCronStoreKysely(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
cronDb
|
||||
.deleteFrom("cron_job_scratch")
|
||||
.where("store_key", "=", storeKey)
|
||||
.where("job_id", "=", jobId),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
options,
|
||||
{ operationLabel: "cron.scratch.delete" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Hash used by doctor to prove the file it removes is the file it migrated. */
|
||||
export function hashCronScratchSource(content: string): string {
|
||||
return createHash("sha256").update(content, "utf8").digest("hex");
|
||||
}
|
||||
@@ -156,6 +156,17 @@ export class CronService implements CronServiceContract {
|
||||
return await ops.readJob(this.state, id);
|
||||
}
|
||||
|
||||
async readScratch(id: string) {
|
||||
return await ops.readScratch(this.state, id);
|
||||
}
|
||||
|
||||
async writeScratch(
|
||||
id: string,
|
||||
params: { content: string | null; expectedRevision?: number; sourceSha256?: string },
|
||||
) {
|
||||
return await ops.writeScratch(this.state, id, params);
|
||||
}
|
||||
|
||||
async recordExternalFailure(
|
||||
id: string,
|
||||
error: string,
|
||||
|
||||
@@ -472,7 +472,10 @@ function assertMainSessionAgentId(
|
||||
if (!job.agentId) {
|
||||
return;
|
||||
}
|
||||
if (job.payload.kind === "script") {
|
||||
// Script payloads run no agent turn; heartbeat monitors only poke the wake
|
||||
// bus and the heartbeat runner resolves the owning agent's main session
|
||||
// itself, so both are valid for non-default agents.
|
||||
if (job.payload.kind === "script" || job.payload.kind === "heartbeat") {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentId(job.agentId);
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
import { resolveCronListSnapshotRevision } from "../list-snapshot-revision.js";
|
||||
import { createCronRunDiagnosticsFromError } from "../run-diagnostics.js";
|
||||
import { cronSchedulingInputsEqual } from "../schedule-identity.js";
|
||||
import {
|
||||
deleteCronJobScratch,
|
||||
readCronJobScratchState,
|
||||
writeCronJobScratch,
|
||||
} from "../scratch-store.js";
|
||||
import { createCronStreamSourceIdentity, cronStreamScheduleKey } from "../stream-schedule.js";
|
||||
import { normalizeCronTaskRunJobId } from "../task-run-history.js";
|
||||
import type {
|
||||
@@ -361,6 +366,39 @@ export async function readJob(state: CronServiceState, id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads one job's private scratch state after proving the job exists in this store. */
|
||||
export async function readScratch(state: CronServiceState, id: string) {
|
||||
return await locked(state, async () => {
|
||||
await ensureLoaded(state, { skipRecompute: true });
|
||||
findJobOrThrow(state, id);
|
||||
// Scratch intentionally opens the process-global state DB, matching every
|
||||
// other cron store write in this service (see saveCronJobsStore); threading
|
||||
// injected state-db options through CronServiceState is a service-wide
|
||||
// refactor that must move jobs and scratch together, not scratch alone.
|
||||
return readCronJobScratchState(state.deps.storePath, id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes or clears one job's private scratch under the cron mutation lock. */
|
||||
export async function writeScratch(
|
||||
state: CronServiceState,
|
||||
id: string,
|
||||
params: { content: string | null; expectedRevision?: number; sourceSha256?: string },
|
||||
) {
|
||||
return await locked(state, async () => {
|
||||
await ensureLoaded(state, { skipRecompute: true });
|
||||
findJobOrThrow(state, id);
|
||||
return writeCronJobScratch({
|
||||
storePath: state.deps.storePath,
|
||||
jobId: id,
|
||||
content: params.content,
|
||||
expectedRevision: params.expectedRevision,
|
||||
sourceSha256: params.sourceSha256,
|
||||
nowMs: state.deps.nowMs(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a terminal failure from a scheduler-owned event source. */
|
||||
export async function recordExternalFailure(
|
||||
state: CronServiceState,
|
||||
@@ -941,6 +979,15 @@ export async function remove(
|
||||
postPersistAutoDisableNotifications,
|
||||
suppressScheduledJobId: id,
|
||||
});
|
||||
if (removed) {
|
||||
try {
|
||||
deleteCronJobScratch(state.deps.storePath, id);
|
||||
} catch (error) {
|
||||
// The job deletion is already durable. Scratch cleanup is idempotent and
|
||||
// must not turn a committed removal into a retryable API failure.
|
||||
state.deps.log.warn({ jobId: id, err: String(error) }, "cron: scratch cleanup failed");
|
||||
}
|
||||
}
|
||||
armTimer(state);
|
||||
if (removed) {
|
||||
emit(state, { jobId: id, action: "removed", job: removedJob });
|
||||
@@ -1002,6 +1049,16 @@ export async function removeAgentJobsTransactional<T>(
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const job of removedJobs) {
|
||||
try {
|
||||
deleteCronJobScratch(state.deps.storePath, job.id);
|
||||
} catch (error) {
|
||||
state.deps.log.warn(
|
||||
{ jobId: job.id, err: String(error) },
|
||||
"cron: agent scratch cleanup failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
armTimer(state);
|
||||
for (const job of removedJobs) {
|
||||
emit(state, { jobId: job.id, action: "removed", job });
|
||||
|
||||
@@ -64,6 +64,15 @@ export function resolveCronJobsStorePath(storePath?: string, env: NodeJS.Process
|
||||
return resolveDefaultCronStorePath(env);
|
||||
}
|
||||
|
||||
/** Resolves the active cron partition from runtime config and environment. */
|
||||
export function resolveCronJobsStorePathFromConfig(
|
||||
cfg: { cron?: unknown },
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const store = (cfg.cron as { store?: unknown } | undefined)?.store;
|
||||
return resolveCronJobsStorePath(typeof store === "string" ? store : undefined, env);
|
||||
}
|
||||
|
||||
/** Loads cron jobs plus config/runtime sidecars from the SQLite-backed store. */
|
||||
export async function loadCronJobsStoreWithConfigJobs(storePath: string): Promise<LoadedCronStore> {
|
||||
const resolvedStorePath = path.resolve(storePath);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getNodeSqliteKysely } from "../../infra/kysely-sync.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
|
||||
|
||||
type CronJobsTable = OpenClawStateKyselyDatabase["cron_jobs"];
|
||||
type CronStoreDatabase = Pick<OpenClawStateKyselyDatabase, "cron_jobs">;
|
||||
type CronStoreDatabase = Pick<OpenClawStateKyselyDatabase, "cron_job_scratch" | "cron_jobs">;
|
||||
|
||||
/** Read shape for rows in the cron_jobs SQLite table. */
|
||||
export type CronJobRow = Selectable<CronJobsTable>;
|
||||
|
||||
@@ -106,6 +106,8 @@ const mocks = vi.hoisted(() => ({
|
||||
collectDiskSpaceHealthFindings: vi.fn((): readonly HealthFinding[] => []),
|
||||
collectHeartbeatTemplateHealthFindings: vi.fn(async () => [] as unknown[]),
|
||||
maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
collectHeartbeatScratchMigrationFindings: vi.fn(async () => [] as unknown[]),
|
||||
maybeMigrateHeartbeatFilesToScratch: vi.fn().mockResolvedValue({ changes: [], warnings: [] }),
|
||||
collectWhatsappResponsivenessHealthFindings: vi.fn((): readonly HealthFinding[] => []),
|
||||
noteWhatsappResponsivenessHealth: vi.fn().mockResolvedValue(undefined),
|
||||
collectDevicePairingHealthFindings: vi.fn(async () => []),
|
||||
@@ -403,6 +405,11 @@ vi.mock("../commands/doctor-heartbeat-template-repair.js", () => ({
|
||||
maybeRepairHeartbeatTemplate: mocks.maybeRepairHeartbeatTemplate,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor-heartbeat-scratch-migration.js", () => ({
|
||||
collectHeartbeatScratchMigrationFindings: mocks.collectHeartbeatScratchMigrationFindings,
|
||||
maybeMigrateHeartbeatFilesToScratch: mocks.maybeMigrateHeartbeatFilesToScratch,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor-whatsapp-responsiveness.js", () => ({
|
||||
collectWhatsappResponsivenessHealthFindings: mocks.collectWhatsappResponsivenessHealthFindings,
|
||||
noteWhatsappResponsivenessHealth: mocks.noteWhatsappResponsivenessHealth,
|
||||
@@ -668,6 +675,10 @@ describe("doctor health contributions", () => {
|
||||
mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValue([]);
|
||||
mocks.maybeRepairHeartbeatTemplate.mockReset();
|
||||
mocks.maybeRepairHeartbeatTemplate.mockResolvedValue(undefined);
|
||||
mocks.collectHeartbeatScratchMigrationFindings.mockReset();
|
||||
mocks.collectHeartbeatScratchMigrationFindings.mockResolvedValue([]);
|
||||
mocks.maybeMigrateHeartbeatFilesToScratch.mockReset();
|
||||
mocks.maybeMigrateHeartbeatFilesToScratch.mockResolvedValue({ changes: [], warnings: [] });
|
||||
mocks.collectWhatsappResponsivenessHealthFindings.mockReset();
|
||||
mocks.collectWhatsappResponsivenessHealthFindings.mockReturnValue([]);
|
||||
mocks.noteWhatsappResponsivenessHealth.mockReset();
|
||||
|
||||
@@ -1136,6 +1136,16 @@ async function runHeartbeatTemplateRepairHealth(ctx: DoctorHealthFlowContext): P
|
||||
});
|
||||
}
|
||||
|
||||
async function runHeartbeatScratchMigrationHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeMigrateHeartbeatFilesToScratch } =
|
||||
await import("../commands/doctor-heartbeat-scratch-migration.js");
|
||||
await maybeMigrateHeartbeatFilesToScratch({
|
||||
cfg: ctx.cfg,
|
||||
shouldRepair: ctx.prompter.shouldRepair,
|
||||
env: ctx.env,
|
||||
});
|
||||
}
|
||||
|
||||
async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { doctorShellCompletion } = await import("../commands/doctor-completion.js");
|
||||
await doctorShellCompletion(ctx.runtime, ctx.prompter, {
|
||||
@@ -2197,6 +2207,21 @@ function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
},
|
||||
run: runHeartbeatTemplateRepairHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:heartbeat-scratch-migration",
|
||||
label: "Heartbeat scratch migration",
|
||||
healthChecks: {
|
||||
id: "core/doctor/heartbeat-scratch-migration",
|
||||
description: "Workspace HEARTBEAT.md files must migrate into cron-owned scratch.",
|
||||
defaultEnabled: true,
|
||||
async detect(ctx) {
|
||||
const { collectHeartbeatScratchMigrationFindings } =
|
||||
await import("../commands/doctor-heartbeat-scratch-migration.js");
|
||||
return await collectHeartbeatScratchMigrationFindings(ctx.cfg);
|
||||
},
|
||||
},
|
||||
run: runHeartbeatScratchMigrationHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:shell-completion",
|
||||
label: "Shell completion",
|
||||
|
||||
@@ -51,6 +51,8 @@ const unavailableCron: GatewayCronServiceContract = {
|
||||
enqueueRun: async () => cronUnavailable(),
|
||||
getJob: () => undefined,
|
||||
readJob: async () => undefined,
|
||||
readScratch: async (): Promise<never> => cronUnavailable(),
|
||||
writeScratch: async () => cronUnavailable(),
|
||||
getDefaultAgentId: () => undefined,
|
||||
wake: () => ({ ok: false, reason: "unwakeable-session-key" }),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,8 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"channels.pairing.list",
|
||||
"channels.pairing.approve",
|
||||
"channels.pairing.dismiss",
|
||||
"cron.scratch.get",
|
||||
"cron.scratch.set",
|
||||
] as const;
|
||||
|
||||
describe("core gateway method release trains", () => {
|
||||
|
||||
@@ -278,6 +278,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "cron.get", scope: "operator.read", since: "<=2026.7" },
|
||||
{ name: "cron.list", scope: "operator.read", since: "<=2026.7" },
|
||||
{ name: "cron.status", scope: "operator.read", since: "<=2026.7" },
|
||||
{ name: "cron.scratch.get", scope: "operator.admin", since: "2026.7" },
|
||||
{ name: "cron.scratch.set", scope: "operator.admin", since: "2026.7" },
|
||||
{ name: "cron.add", scope: "operator.admin", since: "<=2026.7" },
|
||||
{ name: "cron.update", scope: "operator.admin", since: "<=2026.7" },
|
||||
{ name: "cron.remove", scope: "operator.admin", since: "<=2026.7" },
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Gateway cron contracts stay separate from the runtime so shared request
|
||||
// types do not pull scheduler implementation dependencies into their graph.
|
||||
import type { CronJobScratchState, CronJobScratchWriteResult } from "../cron/scratch-store.js";
|
||||
import type { CronServiceContract } from "../cron/service-contract.js";
|
||||
|
||||
export type GatewayCronServiceContract = CronServiceContract & {
|
||||
readScratch(id: string): Promise<CronJobScratchState>;
|
||||
writeScratch(
|
||||
id: string,
|
||||
params: { content: string | null; expectedRevision?: number; sourceSha256?: string },
|
||||
): Promise<CronJobScratchWriteResult>;
|
||||
/** Serialize agent-job removal with the roster commit and restore on failure. */
|
||||
removeAgentJobsTransactional<T>(agentId: string, commit: () => Promise<T>): Promise<T>;
|
||||
/** Temporarily disarm ticks without running startup recovery on resume. */
|
||||
|
||||
@@ -101,8 +101,8 @@ describe("reconcileHeartbeatMonitorJobs", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("removes all monitors when heartbeats are disabled", async () => {
|
||||
const add = vi.fn(async () => ({}));
|
||||
it("keeps a stable disabled monitor when heartbeat cadence is disabled", async () => {
|
||||
const add = vi.fn(async (_input: { declarationKey?: string }, _options?: AddOptions) => ({}));
|
||||
const remove = vi.fn(async () => ({ ok: true }));
|
||||
const list = vi.fn(async () => [monitorJob("main")]);
|
||||
const cfg = {
|
||||
@@ -115,8 +115,15 @@ describe("reconcileHeartbeatMonitorJobs", () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(add).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledWith("job-main", { systemOwned: true });
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
expect(add.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
declarationKey: "heartbeat:main",
|
||||
enabled: false,
|
||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: expect.any(Number) },
|
||||
}),
|
||||
);
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps converging other agents when one convergence fails", async () => {
|
||||
|
||||
@@ -3,19 +3,12 @@
|
||||
// heartbeat-enabled agent, reconverged at startup and config reload.
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolveHeartbeatAgents,
|
||||
resolveHeartbeatSchedulerSeed,
|
||||
} from "../infra/heartbeat-runner.js";
|
||||
import { resolveHeartbeatPhaseMs } from "../infra/heartbeat-schedule.js";
|
||||
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
|
||||
heartbeatMonitorAgentId,
|
||||
resolveHeartbeatMonitorSpecs,
|
||||
} from "../cron/heartbeat-monitor.js";
|
||||
import type { CronJob } from "../cron/types.js";
|
||||
import type { GatewayCronServiceContract } from "./server-cron-contract.js";
|
||||
|
||||
const HEARTBEAT_DECLARATION_PREFIX = "heartbeat:";
|
||||
|
||||
function heartbeatMonitorDeclarationKey(agentId: string): string {
|
||||
return `${HEARTBEAT_DECLARATION_PREFIX}${agentId}`;
|
||||
}
|
||||
|
||||
type HeartbeatJobCron = Pick<GatewayCronServiceContract, "add" | "list" | "remove">;
|
||||
|
||||
/**
|
||||
@@ -30,61 +23,40 @@ export async function reconcileHeartbeatMonitorJobs(params: {
|
||||
logger: { warn: (obj: unknown, msg?: string) => void };
|
||||
}): Promise<{ ok: boolean }> {
|
||||
let ok = true;
|
||||
const schedulerSeed = resolveHeartbeatSchedulerSeed();
|
||||
const desired = new Set<string>();
|
||||
for (const agent of resolveHeartbeatAgents(params.cfg)) {
|
||||
const intervalMs = resolveHeartbeatIntervalMs(params.cfg, undefined, agent.heartbeat);
|
||||
if (!intervalMs) {
|
||||
continue;
|
||||
}
|
||||
desired.add(agent.agentId);
|
||||
let jobs: CronJob[];
|
||||
try {
|
||||
jobs = await params.cron.list({ includeDisabled: true });
|
||||
} catch (error) {
|
||||
params.logger.warn({ err: String(error) }, "cron-heartbeat: monitor inventory failed");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const specs = resolveHeartbeatMonitorSpecs(params.cfg, jobs);
|
||||
const desired = new Set(specs.map((spec) => spec.agentId));
|
||||
for (const spec of specs) {
|
||||
try {
|
||||
await params.cron.add(
|
||||
{
|
||||
declarationKey: heartbeatMonitorDeclarationKey(agent.agentId),
|
||||
displayName: `Heartbeat (${agent.agentId})`,
|
||||
name: `heartbeat-${agent.agentId}`,
|
||||
agentId: agent.agentId,
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: intervalMs,
|
||||
anchorMs: resolveHeartbeatPhaseMs({
|
||||
schedulerSeed,
|
||||
agentId: agent.agentId,
|
||||
intervalMs,
|
||||
}),
|
||||
},
|
||||
payload: { kind: "heartbeat" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
},
|
||||
{
|
||||
enabledExplicit: true,
|
||||
systemOwned: true,
|
||||
// Scope declarative matching to real monitors: a pre-existing user
|
||||
// job that happens to hold this key is left untouched.
|
||||
matchesExisting: (job) => job.payload.kind === "heartbeat",
|
||||
},
|
||||
);
|
||||
await params.cron.add(spec.input, {
|
||||
enabledExplicit: true,
|
||||
systemOwned: true,
|
||||
// Scope declarative matching to real monitors: a pre-existing user
|
||||
// job that happens to hold this key is left untouched.
|
||||
matchesExisting: (job) => job.payload.kind === "heartbeat",
|
||||
});
|
||||
} catch (error) {
|
||||
ok = false;
|
||||
params.logger.warn(
|
||||
{ agentId: agent.agentId, err: String(error) },
|
||||
{ agentId: spec.agentId, err: String(error) },
|
||||
"cron-heartbeat: monitor convergence failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs = await params.cron.list({ includeDisabled: true });
|
||||
for (const job of jobs) {
|
||||
const key = job.declarationKey;
|
||||
// Prune only proven monitors: prefix alone must never delete an
|
||||
// unrelated declaration-keyed job that happens to share the namespace.
|
||||
if (!key?.startsWith(HEARTBEAT_DECLARATION_PREFIX) || job.payload.kind !== "heartbeat") {
|
||||
continue;
|
||||
}
|
||||
if (desired.has(key.slice(HEARTBEAT_DECLARATION_PREFIX.length))) {
|
||||
const agentId = heartbeatMonitorAgentId(job);
|
||||
// Disabled heartbeats retain their stable monitor row (and scratch). Only
|
||||
// agents no longer enrolled in heartbeat are pruned.
|
||||
if (!agentId || desired.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await params.cron.remove(job.id, { systemOwned: true });
|
||||
|
||||
@@ -48,6 +48,17 @@ describe("createLazyGatewayCronState", () => {
|
||||
expect(hoisted.buildGatewayCronService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects a configured legacy cron store partition", () => {
|
||||
const customStore = "/tmp/openclaw-custom-cron/jobs.json";
|
||||
const params = createParams();
|
||||
const lazy = createLazyGatewayCronState({
|
||||
...params,
|
||||
cfg: { ...params.cfg, cron: { store: customStore } } as unknown as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(lazy.storePath).toBe(customStore);
|
||||
});
|
||||
|
||||
it("does not build the heavy cron service until an async cron operation needs it", async () => {
|
||||
const cron = createCronService();
|
||||
const state = createCronState(cron);
|
||||
@@ -324,6 +335,8 @@ function createCronService(): GatewayCronServiceContract {
|
||||
enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never),
|
||||
getJob: vi.fn(() => undefined),
|
||||
readJob: vi.fn(async () => undefined),
|
||||
readScratch: vi.fn(async () => ({ currentRevision: 0 })),
|
||||
writeScratch: vi.fn(async () => ({ ok: true, currentRevision: 1 }) as never),
|
||||
getDefaultAgentId: vi.fn(() => "default"),
|
||||
wake: vi.fn(() => ({ ok: true })),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Defers scheduler startup until cron is touched by runtime or API handlers.
|
||||
import type { CliDeps } from "../cli/deps.types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveCronJobsStorePath } from "../cron/store.js";
|
||||
import { resolveCronJobsStorePathFromConfig } from "../cron/store.js";
|
||||
import { createLazyPromiseLoader } from "../shared/lazy-runtime.js";
|
||||
import type { GatewayCronServiceContract } from "./server-cron-contract.js";
|
||||
import type { GatewayCronState } from "./server-cron.js";
|
||||
@@ -27,7 +27,7 @@ type LoadedGatewayCronState = {
|
||||
/** Creates a cron state proxy that imports the real cron service on first use. */
|
||||
export function createLazyGatewayCronState(params: LazyGatewayCronParams): GatewayCronState {
|
||||
const env = params.env ?? process.env;
|
||||
const storePath = resolveCronJobsStorePath(undefined, env);
|
||||
const storePath = resolveCronJobsStorePathFromConfig(params.cfg, env);
|
||||
const cronEnabled = env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
|
||||
let loaded: LoadedGatewayCronState | null = null;
|
||||
let stopped = false;
|
||||
@@ -287,6 +287,12 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew
|
||||
async readJob(id) {
|
||||
return await (await load()).state.cron.readJob(id);
|
||||
},
|
||||
async readScratch(id) {
|
||||
return await (await load()).state.cron.readScratch(id);
|
||||
},
|
||||
async writeScratch(id, write) {
|
||||
return await (await load()).state.cron.writeScratch(id, write);
|
||||
},
|
||||
getDefaultAgentId() {
|
||||
if (!loaded) {
|
||||
return undefined;
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
resolveCronDeliverySessionKey,
|
||||
resolveCronSessionTargetSessionKey,
|
||||
} from "../cron/session-target.js";
|
||||
import { resolveCronJobsStorePath } from "../cron/store.js";
|
||||
import { resolveCronJobsStorePathFromConfig } from "../cron/store.js";
|
||||
import { cronStreamScheduleKey } from "../cron/stream-schedule.js";
|
||||
import { createCronScriptRuntime } from "../cron/trigger-script.js";
|
||||
import type {
|
||||
@@ -272,7 +272,7 @@ export function buildGatewayCronService(params: {
|
||||
}): GatewayCronState {
|
||||
const cronLogger = getChildLogger({ module: "cron" });
|
||||
const env = params.env ?? process.env;
|
||||
const storePath = resolveCronJobsStorePath(undefined, env);
|
||||
const storePath = resolveCronJobsStorePathFromConfig(params.cfg, env);
|
||||
const cronEnabled = env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
|
||||
|
||||
const findAgentEntry = (cfg: OpenClawConfig, agentId: string) =>
|
||||
@@ -1252,13 +1252,13 @@ export function buildGatewayCronService(params: {
|
||||
}
|
||||
};
|
||||
const removeCron = cron.remove.bind(cron);
|
||||
cron.remove = async (jobId) => {
|
||||
cron.remove = async (jobId, opts) => {
|
||||
const previous = cron.getJob(jobId);
|
||||
try {
|
||||
if (previous?.schedule.kind === "stream") {
|
||||
await streamWatchersRef.current?.stop(jobId, "removed", previous);
|
||||
}
|
||||
const result = await removeCron(jobId);
|
||||
const result = await removeCron(jobId, opts);
|
||||
if (!result.removed) {
|
||||
await routeLiveStreamJobLogged(jobId);
|
||||
}
|
||||
|
||||
@@ -461,6 +461,8 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.get",
|
||||
"cron.scratch.get",
|
||||
"cron.scratch.set",
|
||||
"cron.add",
|
||||
"cron.update",
|
||||
"cron.remove",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
validateCronRemoveParams,
|
||||
validateCronRunParams,
|
||||
validateCronRunsParams,
|
||||
validateCronScratchGetParams,
|
||||
validateCronScratchSetParams,
|
||||
validateCronStatusParams,
|
||||
validateCronUpdateParams,
|
||||
validateWakeParams,
|
||||
@@ -25,6 +27,7 @@ import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js";
|
||||
import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js";
|
||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||
import { toPublicCronJob } from "../../cron/public-job.js";
|
||||
import { CRON_JOB_SCRATCH_MAX_BYTES } from "../../cron/scratch-contract.js";
|
||||
import { applyJobPatch } from "../../cron/service/jobs.js";
|
||||
import {
|
||||
isInvalidCronSessionTargetIdError,
|
||||
@@ -89,6 +92,21 @@ class CronJobConfigRevisionConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Migration provenance (sourceSha256) stays internal; the closed result schema
|
||||
// exposes only content/revision/updatedAtMs.
|
||||
function publicCronScratch(
|
||||
scratch: { content: string; revision: number; updatedAtMs: number } | undefined,
|
||||
) {
|
||||
if (!scratch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
content: scratch.content,
|
||||
revision: scratch.revision,
|
||||
updatedAtMs: scratch.updatedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function cronJobReadView(job: CronJob) {
|
||||
const publicJob = toPublicCronJob(job);
|
||||
return {
|
||||
@@ -518,6 +536,98 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, cronJobReadView(job), undefined);
|
||||
},
|
||||
"cron.scratch.get": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronScratchGetParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
"cron.scratch.get",
|
||||
formatValidationErrors(validateCronScratchGetParams.errors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const jobId = resolveCronJobId(params as CronJobIdParams);
|
||||
if (!jobId) {
|
||||
respondMissingCronJobId(respond, "cron.scratch.get");
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (
|
||||
!job ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
respondInvalidCronParams(respond, "cron.scratch.get", "id not found");
|
||||
return;
|
||||
}
|
||||
const state = await context.cron.readScratch(jobId);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
scratch: publicCronScratch(state.scratch),
|
||||
currentRevision: state.currentRevision,
|
||||
maxBytes: CRON_JOB_SCRATCH_MAX_BYTES,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"cron.scratch.set": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronScratchSetParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
"cron.scratch.set",
|
||||
formatValidationErrors(validateCronScratchSetParams.errors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as CronJobIdParams & {
|
||||
content: string | null;
|
||||
expectedRevision?: number;
|
||||
};
|
||||
const jobId = resolveCronJobId(p);
|
||||
if (!jobId) {
|
||||
respondMissingCronJobId(respond, "cron.scratch.set");
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (
|
||||
!job ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
respondInvalidCronParams(respond, "cron.scratch.set", "id not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await context.cron.writeScratch(jobId, {
|
||||
content: p.content,
|
||||
expectedRevision: p.expectedRevision,
|
||||
});
|
||||
if (!result.ok) {
|
||||
respond(true, result, undefined);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
scratch: publicCronScratch(result.scratch),
|
||||
currentRevision: result.currentRevision,
|
||||
maxBytes: CRON_JOB_SCRATCH_MAX_BYTES,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
respondInvalidCronParams(respond, "cron.scratch.set", formatErrorMessage(error));
|
||||
}
|
||||
},
|
||||
"cron.add": async ({ params, respond, context, client }) => {
|
||||
const rawParams = params as {
|
||||
declarationKey?: unknown;
|
||||
|
||||
+7
-11
@@ -1,5 +1,4 @@
|
||||
// Covers heartbeat ack truncation limits.
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
|
||||
@@ -7,6 +6,7 @@ import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harne
|
||||
import {
|
||||
type HeartbeatReplySpy,
|
||||
readSessionStoreForTest,
|
||||
seedHeartbeatScratchForTest,
|
||||
seedMainSessionStore,
|
||||
seedSessionStore,
|
||||
withTempHeartbeatSandbox,
|
||||
@@ -274,15 +274,13 @@ describe("runHeartbeatOnce ack handling", () => {
|
||||
it("records completed tasks when HEARTBEAT_OK delivery fails", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const nowMs = Date.parse("2026-07-06T12:00:00.000Z");
|
||||
await fs.writeFile(
|
||||
`${tmpDir}/HEARTBEAT.md`,
|
||||
`tasks:
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: check-deployment
|
||||
interval: 5m
|
||||
prompt: Check deployment status
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
const cfg = createHeartbeatConfig({
|
||||
tmpDir,
|
||||
storePath,
|
||||
@@ -325,15 +323,13 @@ describe("runHeartbeatOnce ack handling", () => {
|
||||
it("records completed tasks when HEARTBEAT_OK readiness checks fail", async () => {
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const nowMs = Date.parse("2026-07-06T12:00:00.000Z");
|
||||
await fs.writeFile(
|
||||
`${tmpDir}/HEARTBEAT.md`,
|
||||
`tasks:
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: check-deployment
|
||||
interval: 5m
|
||||
prompt: Check deployment status
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
const cfg = createWhatsAppHeartbeatConfig({
|
||||
tmpDir,
|
||||
storePath,
|
||||
@@ -1,6 +1,4 @@
|
||||
// Covers heartbeat commitment checks and runner scheduling behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { listDueCommitmentSessionKeys } from "../commitments/store.js";
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
import {
|
||||
readSessionStoreForTest,
|
||||
seedHeartbeatScratchForTest,
|
||||
seedSessionStore,
|
||||
withTempHeartbeatSandbox,
|
||||
} from "./heartbeat-runner.test-utils.js";
|
||||
@@ -230,15 +229,13 @@ describe("runHeartbeatOnce commitments", () => {
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, "HEARTBEAT.md"),
|
||||
`tasks:
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: deployment-status
|
||||
interval: 5m
|
||||
prompt: Check deployment status with the normal tools
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
await seedSessionStore(storePath, sessionKey, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
@@ -695,7 +692,7 @@ describe("runHeartbeatOnce commitments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("appends HEARTBEAT.md directives to commitment prompt when tasks are configured but none are due", async () => {
|
||||
it("appends scratch directives to commitment prompt when tasks are configured but none are due", async () => {
|
||||
const { result, sendTelegram, store } = await withTempHeartbeatSandbox(
|
||||
async ({ tmpDir, storePath, replySpy }) => {
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir);
|
||||
@@ -713,18 +710,16 @@ describe("runHeartbeatOnce commitments", () => {
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
// HEARTBEAT.md has a tasks block (task ran recently — NOT due) plus extra prose directives.
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, "HEARTBEAT.md"),
|
||||
`Do not contact the user unless critical.
|
||||
// Scratch has a tasks block (task ran recently — NOT due) plus extra prose directives.
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `Do not contact the user unless critical.
|
||||
|
||||
tasks:
|
||||
- name: check-deployment
|
||||
interval: 5m
|
||||
prompt: Check deployment status
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
// Seed heartbeatTaskState so the task ran at nowMs (well within 5m interval, not due).
|
||||
await seedSessionStore(storePath, sessionKey, {
|
||||
sessionId: "sid",
|
||||
@@ -748,7 +743,7 @@ tasks:
|
||||
// Must contain commitment text
|
||||
expect(ctx.Body).toContain("Due inferred follow-up commitments");
|
||||
expect(ctx.Body).toContain("How did the interview go?");
|
||||
// Must also contain HEARTBEAT.md directives outside the tasks block
|
||||
// Must also contain scratch directives outside the tasks block
|
||||
expect(ctx.Body).toContain("Do not contact the user unless critical.");
|
||||
// Must NOT contain the task prompt (task is not due)
|
||||
expect(ctx.Body).not.toContain("Check deployment status");
|
||||
@@ -804,17 +799,15 @@ tasks:
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, "HEARTBEAT.md"),
|
||||
`Run the global operations audit and surface any unrelated account drift.
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `Run the global operations audit and surface any unrelated account drift.
|
||||
|
||||
tasks:
|
||||
- name: global-ops-audit
|
||||
interval: 5m
|
||||
prompt: Check every dashboard for unrelated account drift
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
await seedSessionStore(storePath, sessionKey, {
|
||||
sessionId: "sid",
|
||||
updatedAt: nowMs,
|
||||
@@ -854,8 +847,8 @@ tasks:
|
||||
expect(ctx.Body).not.toContain("global operations audit");
|
||||
expect(ctx.Body).not.toContain("unrelated account drift");
|
||||
expect(ctx.Body).not.toContain("Run the following periodic tasks");
|
||||
expect(ctx.Body).not.toContain("Additional context from HEARTBEAT.md");
|
||||
expect(ctx.Body).not.toContain("Read HEARTBEAT.md");
|
||||
expect(ctx.Body).not.toContain("Heartbeat monitor scratch:");
|
||||
expect(ctx.Body).not.toContain("HEARTBEAT.md");
|
||||
expect(ctx.OriginatingChannel).toBe("telegram");
|
||||
expect(ctx.OriginatingTo).toBe("155462274");
|
||||
expect(opts?.disableTools).toBe(true);
|
||||
|
||||
@@ -627,7 +627,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
expect(firstCtx.Provider).toBe("cron-event");
|
||||
expect(firstCtx.Body).toContain("Cron: QMD maintenance completed");
|
||||
expect(secondCtx.Provider).toBe("heartbeat");
|
||||
expect(secondCtx.Body).toContain("Read HEARTBEAT.md");
|
||||
expect(secondCtx.Body).toContain("Heartbeat monitor scratch:");
|
||||
expect(secondCtx.Body).not.toContain("Cron: QMD maintenance completed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Covers heartbeat system-event isolation by stable session keys.
|
||||
import fs from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
@@ -7,6 +6,7 @@ import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
import {
|
||||
readSessionStoreForTest,
|
||||
seedHeartbeatScratchForTest,
|
||||
seedSessionStore,
|
||||
withTempHeartbeatSandbox,
|
||||
} from "./heartbeat-runner.test-utils.js";
|
||||
@@ -386,15 +386,13 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => {
|
||||
};
|
||||
const baseSessionKey = resolveMainSessionKey(cfg);
|
||||
const isolatedSessionKey = `${baseSessionKey}:heartbeat`;
|
||||
await fs.writeFile(
|
||||
`${tmpDir}/HEARTBEAT.md`,
|
||||
`tasks:
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: daily-check
|
||||
interval: 1d
|
||||
prompt: "Check status"
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
await seedSessionStore(storePath, baseSessionKey, {
|
||||
sessionId: "sid",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Covers isolated heartbeat outbound session routing and base-session bookkeeping.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
@@ -8,6 +6,7 @@ import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
import {
|
||||
readSessionStoreForTest,
|
||||
seedHeartbeatScratchForTest,
|
||||
seedSessionStore,
|
||||
withTempHeartbeatSandbox,
|
||||
} from "./heartbeat-runner.test-utils.js";
|
||||
@@ -69,15 +68,13 @@ describe("runHeartbeatOnce - isolated heartbeat outbound session mirror", () =>
|
||||
const baseSessionKey = resolveMainSessionKey(cfg);
|
||||
const isolatedSessionKey = `${baseSessionKey}:heartbeat`;
|
||||
const nowMs = Date.now();
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, "HEARTBEAT.md"),
|
||||
`tasks:
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: check-in
|
||||
interval: 5m
|
||||
prompt: "Check whether the user needs a status update."
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
await seedSessionStore(storePath, baseSessionKey, {
|
||||
sessionId: "base-session",
|
||||
updatedAt: nowMs - 1_000,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// Regression test for bounded HEARTBEAT.md reads.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
import { seedMainSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
|
||||
|
||||
installHeartbeatRunnerTestRuntime({ includeSlack: true });
|
||||
|
||||
afterEach(() => {
|
||||
loggingState.rawConsole = null;
|
||||
setLoggerOverride(null);
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
describe("runHeartbeatOnce oversized HEARTBEAT.md", () => {
|
||||
it("follows a symlinked HEARTBEAT.md to a regular file", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Symlink support in unit tests is not guaranteed on Windows CI runners.
|
||||
return;
|
||||
}
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: { every: "5m", target: "slack", to: "channel:C123" },
|
||||
},
|
||||
},
|
||||
channels: { slack: { heartbeat: { showOk: false } } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
await seedMainSessionStore(storePath, cfg, {
|
||||
lastChannel: "slack",
|
||||
lastProvider: "slack",
|
||||
lastTo: "channel:C123",
|
||||
});
|
||||
const heartbeatPath = path.join(tmpDir, "HEARTBEAT.md");
|
||||
const targetPath = path.join(tmpDir, "real-HEARTBEAT.md");
|
||||
await fs.writeFile(targetPath, "- Check status\n", "utf-8");
|
||||
await fs.rm(heartbeatPath, { force: true });
|
||||
await fs.symlink(targetPath, heartbeatPath);
|
||||
|
||||
replySpy.mockResolvedValue({ text: "ok" });
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m1", channelId: "C123" });
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
getReplyFromConfig: replySpy,
|
||||
slack: sendSlack,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ran");
|
||||
expect(sendSlack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an oversized HEARTBEAT.md like a missing file and continues the run", async () => {
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: { every: "5m", target: "slack", to: "channel:C123" },
|
||||
},
|
||||
},
|
||||
channels: { slack: { heartbeat: { showOk: false } } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
await seedMainSessionStore(storePath, cfg, {
|
||||
lastChannel: "slack",
|
||||
lastProvider: "slack",
|
||||
lastTo: "channel:C123",
|
||||
});
|
||||
// Overwrite the default heartbeat file with content larger than the 16 MB cap.
|
||||
const oversizedContent = Buffer.alloc(16 * 1024 * 1024 + 1, "x");
|
||||
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), oversizedContent);
|
||||
|
||||
const warn = vi.fn();
|
||||
loggingState.rawConsole = { log: vi.fn(), info: vi.fn(), warn, error: vi.fn() };
|
||||
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
|
||||
|
||||
replySpy.mockResolvedValue({ text: "needs attention" });
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m1", channelId: "C123" });
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
getReplyFromConfig: replySpy,
|
||||
slack: sendSlack,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ran");
|
||||
expect(sendSlack).toHaveBeenCalledTimes(1);
|
||||
// Operators must see why their oversized heartbeat file no longer applies.
|
||||
expect(
|
||||
warn.mock.calls.some((call) => String(call[0]).includes("skipping oversized HEARTBEAT.md")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../config/sessions.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
createDirectOutboundTestAdapter,
|
||||
createOutboundTestPlugin,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
resolveHeartbeatPrompt,
|
||||
runHeartbeatOnce,
|
||||
} from "./heartbeat-runner.js";
|
||||
import { seedSessionStore } from "./heartbeat-runner.test-utils.js";
|
||||
import { seedHeartbeatScratchForTest, seedSessionStore } from "./heartbeat-runner.test-utils.js";
|
||||
import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveHeartbeatDeliveryTargetWithSessionRoute,
|
||||
@@ -41,6 +42,7 @@ let testRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
||||
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
let previousStateDir: string | undefined;
|
||||
|
||||
function normalizeWhatsAppTargetForTest(raw: string): string | null {
|
||||
const trimmed = raw
|
||||
@@ -325,6 +327,8 @@ beforeAll(async () => {
|
||||
setActivePluginRegistry(testRegistry);
|
||||
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-suite-"));
|
||||
previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(fixtureRoot, "state");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -335,6 +339,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1419,17 +1429,16 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
type HeartbeatFileState =
|
||||
type HeartbeatScratchState =
|
||||
| "empty"
|
||||
| "actionable"
|
||||
| "legacy-comment-only"
|
||||
| "fenced-empty"
|
||||
| "fenced-actionable"
|
||||
| "missing"
|
||||
| "read-error";
|
||||
| "missing";
|
||||
|
||||
async function runHeartbeatFileScenario(params: {
|
||||
fileState: HeartbeatFileState;
|
||||
fileState: HeartbeatScratchState;
|
||||
source?: "notifications-event";
|
||||
reason?: "interval" | "wake";
|
||||
unscheduled?: boolean;
|
||||
@@ -1442,57 +1451,33 @@ describe("runHeartbeatOnce", () => {
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
if (params.fileState === "empty") {
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
||||
"utf-8",
|
||||
);
|
||||
} else if (params.fileState === "legacy-comment-only") {
|
||||
// Compatibility case for the pre-198de10523 template shape, before the
|
||||
// docs template started wrapping the scaffold in a fenced ```markdown block.
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
`# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
const scratchContent =
|
||||
params.fileState === "empty"
|
||||
? "# Heartbeat scratch\n\n## Tasks\n\n"
|
||||
: params.fileState === "legacy-comment-only"
|
||||
? `# Keep this empty (or with only comments) to skip heartbeat API calls.
|
||||
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
} else if (params.fileState === "fenced-empty") {
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
`# HEARTBEAT.md Template
|
||||
`
|
||||
: params.fileState === "fenced-empty"
|
||||
? `# Heartbeat scratch template
|
||||
|
||||
\`\`\`markdown
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Keep this empty (or with only comments) to skip heartbeat API calls.
|
||||
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
\`\`\`
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
} else if (params.fileState === "actionable") {
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
"# HEARTBEAT.md\n\n- Check server logs\n- Review pending PRs\n",
|
||||
"utf-8",
|
||||
);
|
||||
} else if (params.fileState === "fenced-actionable") {
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
`\`\`\`markdown
|
||||
# Keep this file empty when you want to skip.
|
||||
`
|
||||
: params.fileState === "actionable"
|
||||
? "# Heartbeat scratch\n\n- Check server logs\n- Review pending PRs\n"
|
||||
: params.fileState === "fenced-actionable"
|
||||
? `\`\`\`markdown
|
||||
# Keep this empty when you want to skip.
|
||||
|
||||
- Check server logs
|
||||
\`\`\`
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
} else if (params.fileState === "read-error") {
|
||||
// readFile on a directory triggers EISDIR.
|
||||
await fs.mkdir(path.join(workspaceDir, "HEARTBEAT.md"), { recursive: true });
|
||||
}
|
||||
`
|
||||
: null;
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -1504,6 +1489,7 @@ describe("runHeartbeatOnce", () => {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
await seedHeartbeatScratchForTest({ content: scratchContent });
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
await seedWhatsAppSession(storePath, sessionKey);
|
||||
if (params.queueCronEvent) {
|
||||
@@ -1540,8 +1526,8 @@ describe("runHeartbeatOnce", () => {
|
||||
return { res, replySpy, sendWhatsApp, workspaceDir };
|
||||
}
|
||||
|
||||
it("adds explicit workspace HEARTBEAT.md path guidance to heartbeat prompts", async () => {
|
||||
const { res, replySpy, sendWhatsApp, workspaceDir } = await runHeartbeatFileScenario({
|
||||
it("injects actionable monitor scratch without workspace file guidance", async () => {
|
||||
const { res, replySpy, sendWhatsApp } = await runHeartbeatFileScenario({
|
||||
fileState: "actionable",
|
||||
reason: "interval",
|
||||
replyText: "Checked logs and PRs",
|
||||
@@ -1551,22 +1537,77 @@ describe("runHeartbeatOnce", () => {
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const calledCtx = replyBody(replySpy);
|
||||
const expectedPath = path.join(workspaceDir, "HEARTBEAT.md").replace(/\\/g, "/");
|
||||
expect(calledCtx.Body).toContain(`use workspace file ${expectedPath} (exact case)`);
|
||||
expect(calledCtx.Body).toContain("Do not read docs/heartbeat.md.");
|
||||
expect(calledCtx.Body).toContain("Heartbeat monitor scratch:");
|
||||
expect(calledCtx.Body).toContain("Check server logs");
|
||||
expect(calledCtx.Body).not.toContain("HEARTBEAT.md");
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-task HEARTBEAT.md context while stripping blank-line-separated task blocks", async () => {
|
||||
const tmpDir = await createCaseDir("openclaw-hb-tasks-context");
|
||||
it("keeps legacy HEARTBEAT.md active until doctor migrates it", async () => {
|
||||
const tmpDir = await createCaseDir("openclaw-hb-legacy-fallback");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
`# Keep this header
|
||||
"# Legacy instructions\n\n- Check the deployment\n",
|
||||
"utf8",
|
||||
);
|
||||
const legacyCronStore = path.join(tmpDir, "legacy-cron", "jobs.json");
|
||||
await seedHeartbeatScratchForTest({ content: null, storePath: legacyCronStore });
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: workspaceDir, heartbeat: { every: "5m" } } },
|
||||
cron: { store: legacyCronStore },
|
||||
session: { store: storePath },
|
||||
} as unknown as OpenClawConfig;
|
||||
await seedWhatsAppSession(storePath, resolveMainSessionKey(cfg));
|
||||
const replySpy = vi.fn().mockResolvedValue({ text: "Checked deployment" });
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: createHeartbeatDeps(vi.fn(), { getReplyFromConfig: replySpy }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(replyBody(replySpy).Body).toContain("Check the deployment");
|
||||
});
|
||||
|
||||
it("reads heartbeat scratch from a configured cron store partition", async () => {
|
||||
const tmpDir = await createCaseDir("openclaw-hb-custom-store");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const customCronStore = path.join(tmpDir, "custom-cron", "jobs.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: "- Check the custom cron partition\n",
|
||||
storePath: customCronStore,
|
||||
});
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: workspaceDir, heartbeat: { every: "5m" } } },
|
||||
cron: { store: customCronStore },
|
||||
session: { store: storePath },
|
||||
} as unknown as OpenClawConfig;
|
||||
await seedWhatsAppSession(storePath, resolveMainSessionKey(cfg));
|
||||
const replySpy = vi.fn().mockResolvedValue({ text: "Checked custom partition" });
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: createHeartbeatDeps(vi.fn(), { getReplyFromConfig: replySpy }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(replyBody(replySpy).Body).toContain("Check the custom cron partition");
|
||||
});
|
||||
|
||||
it("keeps non-task scratch context while stripping blank-line-separated task blocks", async () => {
|
||||
const tmpDir = await createCaseDir("openclaw-hb-tasks-context");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `# Keep this header
|
||||
|
||||
Remember escalation policy.
|
||||
|
||||
@@ -1583,8 +1624,7 @@ Some global directive after tasks.
|
||||
|
||||
- Keep this top-level directive too.
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -1614,7 +1654,7 @@ Some global directive after tasks.
|
||||
const calledCtx = replyBody(replySpy);
|
||||
expect(calledCtx.Body).toContain("- inbox: Check urgent inbox items");
|
||||
expect(calledCtx.Body).toContain("- calendar: Check calendar changes");
|
||||
expect(calledCtx.Body).toContain("Additional context from HEARTBEAT.md");
|
||||
expect(calledCtx.Body).toContain("Heartbeat monitor scratch");
|
||||
expect(calledCtx.Body).toContain("# Keep this header");
|
||||
expect(calledCtx.Body).toContain("Remember escalation policy.");
|
||||
expect(calledCtx.Body).toContain("Some global directive after tasks.");
|
||||
@@ -1629,9 +1669,8 @@ Some global directive after tasks.
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
`# Keep this header
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `# Keep this header
|
||||
|
||||
tasks:
|
||||
- name: inbox
|
||||
@@ -1644,8 +1683,7 @@ tasks:
|
||||
|
||||
- Keep this top-level directive after tasks.
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -1675,7 +1713,7 @@ tasks:
|
||||
const calledCtx = replyBody(replySpy);
|
||||
expect(calledCtx.Body).toContain("- inbox: Check urgent inbox items");
|
||||
expect(calledCtx.Body).toContain("- calendar: Check calendar changes");
|
||||
expect(calledCtx.Body).toContain("Additional context from HEARTBEAT.md");
|
||||
expect(calledCtx.Body).toContain("Heartbeat monitor scratch");
|
||||
expect(calledCtx.Body).toContain("# Keep this header");
|
||||
expect(calledCtx.Body).toContain("- Keep this top-level directive after tasks.");
|
||||
expect(calledCtx.Body).not.toContain("name: inbox");
|
||||
@@ -1685,10 +1723,10 @@ tasks:
|
||||
replySpy.mockReset();
|
||||
});
|
||||
|
||||
it("applies HEARTBEAT.md gating rules across file states and triggers", async () => {
|
||||
it("applies scratch gating rules across content states and triggers", async () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
fileState: HeartbeatFileState;
|
||||
fileState: HeartbeatScratchState;
|
||||
reason?: "interval" | "wake";
|
||||
source?: "notifications-event";
|
||||
unscheduled?: boolean;
|
||||
@@ -1778,13 +1816,6 @@ tasks:
|
||||
expectedSendCalls: 1,
|
||||
expectedReplyCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "read error runs",
|
||||
fileState: "read-error",
|
||||
expectedStatus: "ran",
|
||||
expectedSendCalls: 1,
|
||||
expectedReplyCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "missing file + wake runs",
|
||||
fileState: "missing",
|
||||
|
||||
@@ -8,7 +8,11 @@ import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { listSessionEntries, replaceSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { writeCronJobScratch } from "../cron/scratch-store.js";
|
||||
import { CronService } from "../cron/service.js";
|
||||
import { resolveCronJobsStorePath } from "../cron/store.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import type { HeartbeatDeps } from "./heartbeat-runner.js";
|
||||
|
||||
@@ -29,6 +33,43 @@ function createHeartbeatReplySpy(): HeartbeatReplySpy {
|
||||
return replySpy;
|
||||
}
|
||||
|
||||
/** Seed one system heartbeat monitor and its private scratch in the test state DB. */
|
||||
export async function seedHeartbeatScratchForTest(params: {
|
||||
content: string | null;
|
||||
agentId?: string;
|
||||
storePath?: string;
|
||||
}): Promise<string> {
|
||||
const agentId = params.agentId ?? "main";
|
||||
const storePath = params.storePath ?? resolveCronJobsStorePath();
|
||||
const noop = () => {};
|
||||
const cron = new CronService({
|
||||
storePath,
|
||||
cronEnabled: false,
|
||||
defaultAgentId: "main",
|
||||
log: { debug: noop, info: noop, warn: noop, error: noop },
|
||||
enqueueSystemEvent: () => false,
|
||||
requestHeartbeat: noop,
|
||||
runIsolatedAgentJob: async () => ({ status: "skipped", error: "test" }),
|
||||
});
|
||||
const result = await cron.add(
|
||||
{
|
||||
declarationKey: `heartbeat:${agentId}`,
|
||||
displayName: `Heartbeat (${agentId})`,
|
||||
name: `heartbeat-${agentId}`,
|
||||
agentId,
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 30 * 60_000, anchorMs: 0 },
|
||||
payload: { kind: "heartbeat" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
},
|
||||
{ enabledExplicit: true, systemOwned: true },
|
||||
);
|
||||
const job = "job" in result ? result.job : result;
|
||||
writeCronJobScratch({ storePath, jobId: job.id, content: params.content });
|
||||
return job.id;
|
||||
}
|
||||
|
||||
/** Write a single heartbeat session entry through the SQLite session accessor. */
|
||||
export async function seedSessionStore(
|
||||
storePath: string,
|
||||
@@ -74,18 +115,20 @@ export async function withTempHeartbeatSandbox<T>(
|
||||
},
|
||||
): Promise<T> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-hb-"));
|
||||
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = createHeartbeatReplySpy();
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
for (const envName of options?.unsetEnvVars ?? []) {
|
||||
const envNames = new Set(["OPENCLAW_STATE_DIR", ...(options?.unsetEnvVars ?? [])]);
|
||||
for (const envName of envNames) {
|
||||
previousEnv.set(envName, process.env[envName]);
|
||||
process.env[envName] = "";
|
||||
process.env[envName] = envName === "OPENCLAW_STATE_DIR" ? path.join(tmpDir, "state") : "";
|
||||
}
|
||||
await seedHeartbeatScratchForTest({ content: "- Check status\n" });
|
||||
try {
|
||||
return await fn({ tmpDir, storePath, replySpy });
|
||||
} finally {
|
||||
replySpy.mockReset();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
for (const [envName, previousValue] of previousEnv.entries()) {
|
||||
if (previousValue === undefined) {
|
||||
delete process.env[envName];
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Covers heartbeat tool-response handling and visible reply policy.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
|
||||
import {
|
||||
@@ -17,6 +15,8 @@ import {
|
||||
} from "../auto-reply/reply/agent-runner-failure-copy.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import { readCronJobScratchState } from "../cron/scratch-store.js";
|
||||
import { resolveCronJobsStorePath } from "../cron/store.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { stripTrailingHeartbeatNotifyFalse } from "./heartbeat-delivery-normalization.js";
|
||||
@@ -26,6 +26,7 @@ import { runHeartbeatOnce, testing, type HeartbeatDeps } from "./heartbeat-runne
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
import {
|
||||
readSessionStoreForTest,
|
||||
seedHeartbeatScratchForTest,
|
||||
seedMainSessionStore,
|
||||
withTempTelegramHeartbeatSandbox,
|
||||
} from "./heartbeat-runner.test-utils.js";
|
||||
@@ -287,6 +288,37 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
|
||||
expect(sendTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits a scratch replacement without exposing it as reply channel data", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg = createConfig({ tmpDir, storePath });
|
||||
const jobId = await seedHeartbeatScratchForTest({ content: "old scratch" });
|
||||
await seedMainSessionStore(storePath, cfg, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: TELEGRAM_GROUP,
|
||||
});
|
||||
const reply = createHeartbeatToolResponsePayload({
|
||||
outcome: "progress",
|
||||
notify: false,
|
||||
summary: "Updated monitor context.",
|
||||
scratch: "new private scratch",
|
||||
});
|
||||
expect(JSON.stringify(reply)).not.toContain("new private scratch");
|
||||
replySpy.mockResolvedValue(reply);
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
source: "manual",
|
||||
deps: createDeps({ sendTelegram: vi.fn(), getReplyFromConfig: replySpy }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(readCronJobScratchState(resolveCronJobsStorePath(), jobId).scratch?.content).toBe(
|
||||
"new private scratch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("persists a meaningful quiet outcome for the base session", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
|
||||
@@ -415,15 +447,13 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
|
||||
|
||||
it("delivers a terminal tool warning without recording successful heartbeat bookkeeping", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, "HEARTBEAT.md"),
|
||||
`tasks:
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: check-delivery
|
||||
interval: 1m
|
||||
prompt: Check delivery
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
const cfg = createConfig({ tmpDir, storePath });
|
||||
const sessionKey = await seedMainSessionStore(storePath, cfg, {
|
||||
lastChannel: "telegram",
|
||||
@@ -852,16 +882,14 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
|
||||
it("uses the heartbeat response tool prompt for due heartbeat tasks", async () => {
|
||||
const result = await runPromptScenario({
|
||||
config: { visibleReplies: "message_tool" },
|
||||
beforeSeed: async ({ tmpDir }) => {
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, "HEARTBEAT.md"),
|
||||
`tasks:
|
||||
beforeSeed: async () => {
|
||||
await seedHeartbeatScratchForTest({
|
||||
content: `tasks:
|
||||
- name: status
|
||||
interval: 1m
|
||||
prompt: Check deployment status
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+143
-89
@@ -2,6 +2,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -28,7 +29,10 @@ import {
|
||||
resolveHeartbeatReplyPayload,
|
||||
resolveHeartbeatTerminalToolFailure,
|
||||
} from "../auto-reply/heartbeat-reply-payload.js";
|
||||
import { resolveHeartbeatToolResponseFromReplyResult } from "../auto-reply/heartbeat-tool-response.js";
|
||||
import {
|
||||
resolveHeartbeatScratchProposalFromReplyResult,
|
||||
resolveHeartbeatToolResponseFromReplyResult,
|
||||
} from "../auto-reply/heartbeat-tool-response.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
@@ -93,6 +97,8 @@ import {
|
||||
type CronActiveJobMarker,
|
||||
} from "../cron/active-jobs.js";
|
||||
import { resolveCronSession } from "../cron/isolated-agent/session.js";
|
||||
import { readHeartbeatMonitorScratch, writeCronJobScratch } from "../cron/scratch-store.js";
|
||||
import { resolveCronJobsStorePathFromConfig } from "../cron/store.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getActivePluginChannelRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
@@ -174,6 +180,7 @@ import {
|
||||
resolveHeartbeatDeliveryTargetWithSessionRoute,
|
||||
resolveHeartbeatSenderContext,
|
||||
} from "./outbound/targets.js";
|
||||
import { isPathInside } from "./path-guards.js";
|
||||
import { readRegularFile } from "./regular-file.js";
|
||||
import {
|
||||
consumeSelectedSystemEventEntries,
|
||||
@@ -195,6 +202,42 @@ export type HeartbeatDeps = OutboundSendDeps &
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("gateway/heartbeat");
|
||||
const LEGACY_HEARTBEAT_FILE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const legacyHeartbeatFallbackWarnings = new Set<string>();
|
||||
const legacyHeartbeatDecoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
async function readLegacyHeartbeatFileForMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
}): Promise<string | undefined> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||
const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
try {
|
||||
const workspaceRealPath = await fs.realpath(workspaceDir);
|
||||
const sourceRealPath = await fs.realpath(heartbeatPath);
|
||||
if (sourceRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, sourceRealPath)) {
|
||||
throw new Error("HEARTBEAT.md symlink target escapes the agent workspace");
|
||||
}
|
||||
const file = await readRegularFile({
|
||||
filePath: sourceRealPath,
|
||||
maxBytes: LEGACY_HEARTBEAT_FILE_MAX_BYTES,
|
||||
});
|
||||
const content = legacyHeartbeatDecoder.decode(file.buffer);
|
||||
if (!legacyHeartbeatFallbackWarnings.has(heartbeatPath)) {
|
||||
legacyHeartbeatFallbackWarnings.add(heartbeatPath);
|
||||
log.warn(
|
||||
`heartbeat: using legacy ${DEFAULT_HEARTBEAT_FILENAME}; run openclaw doctor --fix to migrate it into cron scratch`,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
} catch (error) {
|
||||
if (hasErrnoCode(error, "ENOENT")) {
|
||||
return undefined;
|
||||
}
|
||||
log.warn(`heartbeat: legacy file migration fallback failed: ${formatErrorMessage(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const loadHeartbeatRunnerRuntime = createLazyRuntimeModule(
|
||||
() => import("./heartbeat-runner.runtime.js"),
|
||||
@@ -812,7 +855,9 @@ type HeartbeatPreflight = HeartbeatWakePayloadFlags & {
|
||||
shouldInspectPendingEvents: boolean;
|
||||
skipReason?: HeartbeatSkipReason;
|
||||
tasks?: HeartbeatTask[];
|
||||
heartbeatFileContent?: string;
|
||||
scratchJobId?: string;
|
||||
scratchRevision?: number;
|
||||
heartbeatScratchContent?: string;
|
||||
};
|
||||
|
||||
async function resolveHeartbeatPreflight(params: {
|
||||
@@ -879,6 +924,35 @@ async function resolveHeartbeatPreflight(params: {
|
||||
wakeFlags.isCronWake ||
|
||||
wakeFlags.isWakePayload ||
|
||||
hasTaggedCronEvents;
|
||||
let monitorScratch: ReturnType<typeof readHeartbeatMonitorScratch>;
|
||||
let scratchReadOk = false;
|
||||
try {
|
||||
monitorScratch = readHeartbeatMonitorScratch(
|
||||
resolveCronJobsStorePathFromConfig(params.cfg),
|
||||
params.agentId,
|
||||
);
|
||||
scratchReadOk = true;
|
||||
} catch (error) {
|
||||
log.warn(`heartbeat: scratch read failed: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
let heartbeatScratchContent = monitorScratch?.state.scratch?.content;
|
||||
if (
|
||||
!shouldBypassFileGates &&
|
||||
// The legacy fallback needs a proven revision-0 state: a failed database
|
||||
// read must not resurrect retired file instructions past a tombstone.
|
||||
scratchReadOk &&
|
||||
heartbeatScratchContent === undefined &&
|
||||
(monitorScratch?.state.currentRevision ?? 0) === 0
|
||||
) {
|
||||
// Named upgrade bridge: tagged builds shipped HEARTBEAT.md as the only
|
||||
// instruction store. Doctor owns the migration; this read-only fallback
|
||||
// prevents silent loss until one full stable upgrade window has shipped,
|
||||
// after which the fallback and legacy template repair can be deleted.
|
||||
heartbeatScratchContent = await readLegacyHeartbeatFileForMigration({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
}
|
||||
const basePreflight = {
|
||||
...wakeFlags,
|
||||
session,
|
||||
@@ -887,62 +961,44 @@ async function resolveHeartbeatPreflight(params: {
|
||||
dueCommitments,
|
||||
hasTaggedCronEvents,
|
||||
shouldInspectPendingEvents,
|
||||
...(monitorScratch?.jobId
|
||||
? {
|
||||
scratchJobId: monitorScratch.jobId,
|
||||
scratchRevision: monitorScratch.state.currentRevision,
|
||||
}
|
||||
: {}),
|
||||
// Bypass scopes (commitment-only, cron/exec events, wake payloads) stay
|
||||
// self-contained: only the job identity travels so heartbeat_respond can
|
||||
// still persist scratch, never the monitor instructions themselves.
|
||||
...(!shouldBypassFileGates && heartbeatScratchContent !== undefined
|
||||
? { heartbeatScratchContent }
|
||||
: {}),
|
||||
} satisfies Omit<HeartbeatPreflight, "skipReason">;
|
||||
|
||||
if (shouldBypassFileGates) {
|
||||
return basePreflight;
|
||||
}
|
||||
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
const MAX_HEARTBEAT_FILE_BYTES = 16 * 1024 * 1024;
|
||||
let heartbeatFileContent: string | undefined;
|
||||
try {
|
||||
// Resolve symlinks so a HEARTBEAT.md pointing to a regular file keeps
|
||||
// working; missing/broken symlinks still surface as ENOENT below.
|
||||
const resolvedHeartbeatFilePath = await fs.realpath(heartbeatFilePath);
|
||||
heartbeatFileContent = (
|
||||
await readRegularFile({
|
||||
filePath: resolvedHeartbeatFilePath,
|
||||
maxBytes: MAX_HEARTBEAT_FILE_BYTES,
|
||||
})
|
||||
).buffer.toString("utf-8");
|
||||
const tasks = parseHeartbeatTasks(heartbeatFileContent);
|
||||
if (
|
||||
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
|
||||
tasks.length === 0 &&
|
||||
dueCommitments.length === 0
|
||||
) {
|
||||
return {
|
||||
...basePreflight,
|
||||
skipReason: "empty-heartbeat-file",
|
||||
tasks: [],
|
||||
heartbeatFileContent,
|
||||
};
|
||||
}
|
||||
// Return tasks even if file has other content - backward compatible
|
||||
if (heartbeatScratchContent === undefined) {
|
||||
// No scratch row preserves the old missing-file behavior: the model still
|
||||
// gets the generic heartbeat prompt and decides whether anything is due.
|
||||
return basePreflight;
|
||||
}
|
||||
const tasks = parseHeartbeatTasks(heartbeatScratchContent);
|
||||
if (
|
||||
isHeartbeatContentEffectivelyEmpty(heartbeatScratchContent) &&
|
||||
tasks.length === 0 &&
|
||||
dueCommitments.length === 0
|
||||
) {
|
||||
return {
|
||||
...basePreflight,
|
||||
tasks,
|
||||
heartbeatFileContent,
|
||||
skipReason: "empty-heartbeat-file",
|
||||
tasks: [],
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (hasErrnoCode(err, "ENOENT")) {
|
||||
// Missing HEARTBEAT.md is intentional in some setups (for example, when
|
||||
// heartbeat instructions live outside the file), so keep the run active.
|
||||
// The heartbeat prompt already says "if it exists".
|
||||
return basePreflight;
|
||||
}
|
||||
// Oversized files loaded in full before the cap existed, so tell the
|
||||
// operator why their heartbeat instructions no longer apply instead of
|
||||
// dropping them silently. Other read errors keep proceeding as before.
|
||||
if (err instanceof Error && err.message.startsWith("File exceeds")) {
|
||||
log.warn(`heartbeat: skipping oversized ${DEFAULT_HEARTBEAT_FILENAME}: ${err.message}`);
|
||||
}
|
||||
// For other read errors, proceed with heartbeat as before.
|
||||
}
|
||||
|
||||
return basePreflight;
|
||||
return {
|
||||
...basePreflight,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
type HeartbeatPromptResolution = {
|
||||
@@ -971,18 +1027,6 @@ function resolveDueHeartbeatTasks(
|
||||
);
|
||||
}
|
||||
|
||||
function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string): string {
|
||||
if (!/heartbeat\.md/i.test(prompt)) {
|
||||
return prompt;
|
||||
}
|
||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME).replace(/\\/g, "/");
|
||||
const hint = `When reading HEARTBEAT.md, use workspace file ${heartbeatFilePath} (exact case). Do not read docs/heartbeat.md.`;
|
||||
if (prompt.includes(hint)) {
|
||||
return prompt;
|
||||
}
|
||||
return `${prompt}\n${hint}`;
|
||||
}
|
||||
|
||||
function stripHeartbeatTasksBlock(content: string): string {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
@@ -1012,25 +1056,16 @@ function stripHeartbeatTasksBlock(content: string): string {
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the workspace HEARTBEAT.md directives (everything outside the
|
||||
* `tasks:` block) to the prompt. Runs on every heartbeat path that actually
|
||||
* dispatches a model call, so prose-style runbooks (the common case in
|
||||
* production setups) reach the model — not only files that happen to declare
|
||||
* periodic tasks.
|
||||
*/
|
||||
function appendHeartbeatFileDirectives(prompt: string, heartbeatFileContent?: string): string {
|
||||
if (!heartbeatFileContent) {
|
||||
/** Appends monitor scratch prose outside the optional `tasks:` block. */
|
||||
function appendHeartbeatScratch(prompt: string, heartbeatScratchContent?: string): string {
|
||||
if (!heartbeatScratchContent) {
|
||||
return prompt;
|
||||
}
|
||||
const directives = stripHeartbeatTasksBlock(heartbeatFileContent).trim();
|
||||
if (!directives) {
|
||||
const directives = stripHeartbeatTasksBlock(heartbeatScratchContent).trim();
|
||||
if (!directives || prompt.includes(directives)) {
|
||||
return prompt;
|
||||
}
|
||||
if (prompt.includes(directives)) {
|
||||
return prompt;
|
||||
}
|
||||
return `${prompt}\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
|
||||
return `${prompt}\n\nHeartbeat monitor scratch:\n${directives}`;
|
||||
}
|
||||
|
||||
function resolveHeartbeatRunPrompt(params: {
|
||||
@@ -1038,10 +1073,9 @@ function resolveHeartbeatRunPrompt(params: {
|
||||
heartbeat?: HeartbeatConfig;
|
||||
preflight: HeartbeatPreflight;
|
||||
canRelayToUser: boolean;
|
||||
workspaceDir: string;
|
||||
startedAt: number;
|
||||
dueTasks: HeartbeatTask[];
|
||||
heartbeatFileContent?: string;
|
||||
heartbeatScratchContent?: string;
|
||||
useHeartbeatResponseTool: boolean;
|
||||
runScope: HeartbeatRunScope;
|
||||
}): HeartbeatPromptResolution {
|
||||
@@ -1101,7 +1135,7 @@ function resolveHeartbeatRunPrompt(params: {
|
||||
${taskList}
|
||||
|
||||
${completionInstruction}`;
|
||||
const prompt = appendHeartbeatFileDirectives(taskListPrompt, params.heartbeatFileContent);
|
||||
const prompt = appendHeartbeatScratch(taskListPrompt, params.heartbeatScratchContent);
|
||||
return {
|
||||
prompt,
|
||||
hasExecCompletion: false,
|
||||
@@ -1113,7 +1147,7 @@ ${completionInstruction}`;
|
||||
}
|
||||
if (commitmentPrompt) {
|
||||
return {
|
||||
prompt: appendHeartbeatFileDirectives(commitmentPrompt, params.heartbeatFileContent),
|
||||
prompt: appendHeartbeatScratch(commitmentPrompt, params.heartbeatScratchContent),
|
||||
hasExecCompletion: false,
|
||||
hasRelayableExecCompletion: false,
|
||||
hasCronEvents: false,
|
||||
@@ -1145,10 +1179,9 @@ ${completionInstruction}`;
|
||||
: baseUsesHeartbeatResponseTool
|
||||
? resolveHeartbeatResponseToolPrompt(params.cfg, params.heartbeat)
|
||||
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
|
||||
const basePromptWithHint = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
|
||||
const basePromptWithDirectives = appendHeartbeatFileDirectives(
|
||||
basePromptWithHint,
|
||||
params.heartbeatFileContent,
|
||||
const basePromptWithDirectives = appendHeartbeatScratch(
|
||||
basePrompt,
|
||||
params.heartbeatScratchContent,
|
||||
);
|
||||
const prompt = commitmentPrompt
|
||||
? `${basePromptWithDirectives}\n\n${commitmentPrompt}`
|
||||
@@ -1460,7 +1493,6 @@ export async function runHeartbeatOnce(opts: {
|
||||
const canRelayToUser = Boolean(
|
||||
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
|
||||
);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
let useHeartbeatResponseToolPrompt = shouldUseHeartbeatResponseToolPrompt({
|
||||
cfg,
|
||||
agentId,
|
||||
@@ -1474,10 +1506,9 @@ export async function runHeartbeatOnce(opts: {
|
||||
heartbeat,
|
||||
preflight,
|
||||
canRelayToUser,
|
||||
workspaceDir,
|
||||
startedAt,
|
||||
dueTasks: dueHeartbeatTasks,
|
||||
heartbeatFileContent: preflight.heartbeatFileContent,
|
||||
heartbeatScratchContent: preflight.heartbeatScratchContent,
|
||||
useHeartbeatResponseTool: useHeartbeatResponseToolPrompt,
|
||||
runScope,
|
||||
});
|
||||
@@ -1598,10 +1629,9 @@ export async function runHeartbeatOnce(opts: {
|
||||
heartbeat,
|
||||
preflight,
|
||||
canRelayToUser,
|
||||
workspaceDir,
|
||||
startedAt,
|
||||
dueTasks: dueHeartbeatTasks,
|
||||
heartbeatFileContent: preflight.heartbeatFileContent,
|
||||
heartbeatScratchContent: preflight.heartbeatScratchContent,
|
||||
useHeartbeatResponseTool: useHeartbeatResponseToolPrompt,
|
||||
runScope,
|
||||
});
|
||||
@@ -1842,8 +1872,32 @@ export async function runHeartbeatOnce(opts: {
|
||||
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
|
||||
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
|
||||
const heartbeatToolResponse = resolveHeartbeatToolResponseFromReplyResult(replyResult);
|
||||
const heartbeatScratchProposal = resolveHeartbeatScratchProposalFromReplyResult(replyResult);
|
||||
const heartbeatTerminalToolFailure = resolveHeartbeatTerminalToolFailure(replyResult);
|
||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||
if (
|
||||
heartbeatScratchProposal !== undefined &&
|
||||
heartbeatToolResponse &&
|
||||
!heartbeatTerminalToolFailure
|
||||
) {
|
||||
if (!preflight.scratchJobId) {
|
||||
log.warn("heartbeat: scratch update ignored because no monitor job exists");
|
||||
} else {
|
||||
try {
|
||||
const scratchWrite = writeCronJobScratch({
|
||||
storePath: resolveCronJobsStorePathFromConfig(cfg),
|
||||
jobId: preflight.scratchJobId,
|
||||
content: heartbeatScratchProposal,
|
||||
expectedRevision: preflight.scratchRevision ?? 0,
|
||||
});
|
||||
if (!scratchWrite.ok) {
|
||||
log.warn("heartbeat: scratch update lost a concurrent revision race");
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(`heartbeat: scratch update failed: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!heartbeatToolResponse &&
|
||||
(!replyPayload || !hasOutboundReplyContent(replyPayload)) &&
|
||||
|
||||
+10
@@ -408,6 +408,15 @@ export interface ConfigMachineState {
|
||||
value_json: string;
|
||||
}
|
||||
|
||||
export interface CronJobScratch {
|
||||
content: string | null;
|
||||
job_id: string;
|
||||
revision: number;
|
||||
source_sha256: string | null;
|
||||
store_key: string;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface CronJobs {
|
||||
agent_id: string | null;
|
||||
anchor_ms: number | null;
|
||||
@@ -1500,6 +1509,7 @@ export interface DB {
|
||||
commitments: Commitments;
|
||||
config_health_entries: ConfigHealthEntries;
|
||||
config_machine_state: ConfigMachineState;
|
||||
cron_job_scratch: CronJobScratch;
|
||||
cron_jobs: CronJobs;
|
||||
current_conversation_bindings: CurrentConversationBindings;
|
||||
delivery_queue_entries: DeliveryQueueEntries;
|
||||
|
||||
@@ -1327,6 +1327,25 @@ CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_session
|
||||
ON cron_jobs(agent_id, session_key, updated_at DESC, job_id)
|
||||
WHERE agent_id IS NOT NULL OR session_key IS NOT NULL;
|
||||
|
||||
-- Scratch is separate from cron_jobs so scheduler state writes and downgraded
|
||||
-- full-row replacement preserve it. New builds prune rows explicitly on job removal.
|
||||
-- content NULL is a tombstone: it keeps the revision lineage monotonic across
|
||||
-- unset/recreate so stale compare-and-swap writes cannot resurrect old content.
|
||||
CREATE TABLE IF NOT EXISTS cron_job_scratch (
|
||||
store_key TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
content TEXT,
|
||||
revision INTEGER NOT NULL,
|
||||
source_sha256 TEXT,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (store_key, job_id),
|
||||
CHECK (revision >= 1),
|
||||
CHECK (content IS NULL OR length(CAST(content AS BLOB)) <= 262144)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_job_scratch_store_updated
|
||||
ON cron_job_scratch(store_key, updated_at_ms DESC, job_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_log_entries (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
|
||||
@@ -1322,6 +1322,25 @@ CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_session
|
||||
ON cron_jobs(agent_id, session_key, updated_at DESC, job_id)
|
||||
WHERE agent_id IS NOT NULL OR session_key IS NOT NULL;
|
||||
|
||||
-- Scratch is separate from cron_jobs so scheduler state writes and downgraded
|
||||
-- full-row replacement preserve it. New builds prune rows explicitly on job removal.
|
||||
-- content NULL is a tombstone: it keeps the revision lineage monotonic across
|
||||
-- unset/recreate so stale compare-and-swap writes cannot resurrect old content.
|
||||
CREATE TABLE IF NOT EXISTS cron_job_scratch (
|
||||
store_key TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
content TEXT,
|
||||
revision INTEGER NOT NULL,
|
||||
source_sha256 TEXT,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (store_key, job_id),
|
||||
CHECK (revision >= 1),
|
||||
CHECK (content IS NULL OR length(CAST(content AS BLOB)) <= 262144)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_job_scratch_store_updated
|
||||
ON cron_job_scratch(store_key, updated_at_ms DESC, job_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_log_entries (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
|
||||
+3
@@ -1276,6 +1276,9 @@
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"scratch": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
Vendored
+16
-19
@@ -133,7 +133,7 @@
|
||||
"collaborationMode": {
|
||||
"mode": "default",
|
||||
"settings": {
|
||||
"developer_instructions": "This is an OpenClaw heartbeat turn. Apply these instructions only to this heartbeat wake; ordinary chat turns should stay in Codex Default mode.\n\nWhen you are ready to end the heartbeat, prefer the structured `heartbeat_respond` tool so OpenClaw can record the wake outcome and notification decision. If `heartbeat_respond` is not already available and `tool_search` is available, search for `heartbeat_respond`, load it, then call it. Use `notify=false` when nothing should visibly interrupt the user.\n\n### Heartbeats\n\nHeartbeat = useful proactive progress, not chatter. Wake, orient, read HEARTBEAT.md, act.\nAssigned/ongoing work: pursue spirit with judgment. Quiet check counts only if real blocker/urgent interruption.\nNo rote loops; orientation != accomplishment. Prefer action/silent progress.\nNever repetitive \"same/no change/still\" updates.\nInterrupt only for meaningful development/result/blocker/decision/time risk. Unchanged: work, change approach, dig deeper, or silence.\n\n## OpenClaw Agent Soul\n\nOpenClaw loaded these workspace instruction files from the active agent workspace. They are the canonical definitions of who you are, how you think and work, and the human you work alongside. Internalize and follow them accordingly.\n\n### /tmp/openclaw-happy-path/workspace/IDENTITY.md\n\n<IDENTITY.md contents will be here>\n\n### /tmp/openclaw-happy-path/workspace/SOUL.md\n\n<SOUL.md contents will be here>\n\n### /tmp/openclaw-happy-path/workspace/USER.md\n\n<USER.md contents will be here>\n\n## OpenClaw Heartbeat Workspace\n\nHEARTBEAT.md exists in the active agent workspace. Read it before proceeding with this heartbeat, then decide what action is appropriate.\n\n- /tmp/openclaw-happy-path/workspace/HEARTBEAT.md",
|
||||
"developer_instructions": "This is an OpenClaw heartbeat turn. Apply these instructions only to this heartbeat wake; ordinary chat turns should stay in Codex Default mode.\n\nWhen you are ready to end the heartbeat, prefer the structured `heartbeat_respond` tool so OpenClaw can record the wake outcome and notification decision. If `heartbeat_respond` is not already available and `tool_search` is available, search for `heartbeat_respond`, load it, then call it. Use `notify=false` when nothing should visibly interrupt the user.\n\n### Heartbeats\n\nHeartbeat = useful proactive progress, not chatter. Wake, orient, use the provided monitor scratch, act.\nAssigned/ongoing work: pursue spirit with judgment. Quiet check counts only if real blocker/urgent interruption.\nNo rote loops; orientation != accomplishment. Prefer action/silent progress.\nNever repetitive \"same/no change/still\" updates.\nInterrupt only for meaningful development/result/blocker/decision/time risk. Unchanged: work, change approach, dig deeper, or silence.\n\n## OpenClaw Agent Soul\n\nOpenClaw loaded these workspace instruction files from the active agent workspace. They are the canonical definitions of who you are, how you think and work, and the human you work alongside. Internalize and follow them accordingly.\n\n### /tmp/openclaw-happy-path/workspace/IDENTITY.md\n\n<IDENTITY.md contents will be here>\n\n### /tmp/openclaw-happy-path/workspace/SOUL.md\n\n<SOUL.md contents will be here>\n\n### /tmp/openclaw-happy-path/workspace/USER.md\n\n<USER.md contents will be here>",
|
||||
"model": "gpt-5.5",
|
||||
"reasoning_effort": "medium"
|
||||
}
|
||||
@@ -201,8 +201,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
```json
|
||||
{
|
||||
"codexCollaborationModeDeveloperInstructions": {
|
||||
"chars": 1715,
|
||||
"roughTokens": 429
|
||||
"chars": 1507,
|
||||
"roughTokens": 377
|
||||
},
|
||||
"codexModelInstructions": {
|
||||
"chars": 21335,
|
||||
@@ -217,24 +217,24 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 60186,
|
||||
"roughTokens": 15047
|
||||
"chars": 60257,
|
||||
"roughTokens": 15065
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2469,
|
||||
"roughTokens": 618
|
||||
},
|
||||
"totalTextOnly": {
|
||||
"chars": 27105,
|
||||
"roughTokens": 6777
|
||||
"chars": 26885,
|
||||
"roughTokens": 6722
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 87293,
|
||||
"roughTokens": 21824
|
||||
"chars": 87144,
|
||||
"roughTokens": 21786
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1271,
|
||||
"roughTokens": 318
|
||||
"chars": 1259,
|
||||
"roughTokens": 315
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -460,7 +460,7 @@ When you are ready to end the heartbeat, prefer the structured `heartbeat_respon
|
||||
|
||||
### Heartbeats
|
||||
|
||||
Heartbeat = useful proactive progress, not chatter. Wake, orient, read HEARTBEAT.md, act.
|
||||
Heartbeat = useful proactive progress, not chatter. Wake, orient, use the provided monitor scratch, act.
|
||||
Assigned/ongoing work: pursue spirit with judgment. Quiet check counts only if real blocker/urgent interruption.
|
||||
No rote loops; orientation != accomplishment. Prefer action/silent progress.
|
||||
Never repetitive "same/no change/still" updates.
|
||||
@@ -481,12 +481,6 @@ OpenClaw loaded these workspace instruction files from the active agent workspac
|
||||
### /tmp/openclaw-happy-path/workspace/USER.md
|
||||
|
||||
<USER.md contents will be here>
|
||||
|
||||
## OpenClaw Heartbeat Workspace
|
||||
|
||||
HEARTBEAT.md exists in the active agent workspace. Read it before proceeding with this heartbeat, then decide what action is appropriate.
|
||||
|
||||
- /tmp/openclaw-happy-path/workspace/HEARTBEAT.md
|
||||
```
|
||||
|
||||
### User: Turn Input Text
|
||||
@@ -521,7 +515,7 @@ Conversation info (untrusted metadata):
|
||||
}
|
||||
```
|
||||
|
||||
Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. Use heartbeat_respond to report the wake outcome. Set notify=false when nothing needs the user's attention. Set notify=true with notificationText only when the user should be interrupted.
|
||||
Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. Use heartbeat_respond to report the wake outcome. Set notify=false when nothing needs the user's attention. Set notify=true with notificationText only when the user should be interrupted.
|
||||
````
|
||||
|
||||
### Tools: Dynamic Tool Catalog
|
||||
@@ -709,6 +703,9 @@ Full JSON: `codex-dynamic-tools.heartbeat-turn.json`
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"scratch": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts",
|
||||
"src/infra/heartbeat-runner.ack-token-heartbeat-acks.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -230,11 +230,9 @@ describe("happy path prompt snapshots", () => {
|
||||
|
||||
expect(openClawRuntimeInstructions).not.toContain(heartbeatPhrase);
|
||||
expect(collaborationModeInstructions).toContain(heartbeatPhrase);
|
||||
expect(collaborationModeInstructions).toContain("HEARTBEAT.md exists");
|
||||
expect(collaborationModeInstructions).toContain(
|
||||
"/tmp/openclaw-happy-path/workspace/HEARTBEAT.md",
|
||||
);
|
||||
expect(collaborationModeInstructions).not.toContain("<HEARTBEAT.md contents will be here>");
|
||||
// Monitor context now lives in cron scratch; the collaboration prompt must
|
||||
// no longer reference the retired workspace file.
|
||||
expect(collaborationModeInstructions).not.toContain("HEARTBEAT.md");
|
||||
expect(collaborationModeInstructions.split(heartbeatPhrase)).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user