supercode_harness/runs.rs
1//! Observed-tier, READ-ONLY inventory of scheduled-job FIRES across the
2//! harnesses that keep a run store (Domain 11, concept 7).
3//!
4//! A run is one execution of a [`crate::jobs::ScheduledJob`], with its
5//! outcome and — where the harness leaves enough behind to recover it — the
6//! session the fire opened.
7//!
8//! * **Hermes** — `HERMES_HOME/cron/executions.db`, a profile-local SQLite
9//! audit ledger (`cron/executions.py`: "the ledger records what is known
10//! about each attempt; it is not a retry queue"). One `executions` row per
11//! attempt: `id, job_id, source, process_id, pid, process_started_at,
12//! status, claimed_at, started_at, finished_at, error`, status one of
13//! `claimed | running | completed | failed | unknown`. A Hermes profile home
14//! is a full HERMES_HOME, so each `profiles/<name>/` has its own ledger.
15//! * **OpenClaw** — `cron_run_logs` in the shared state database
16//! (`<state dir>/state/openclaw.sqlite`) at the pinned 2026.7.1-2:
17//! `store_key, job_id, seq, ts, status, error, …, session_id, session_key,
18//! run_id, run_at_ms, duration_ms, …, entry_json`, status one of
19//! `ok | error | skipped`. `store_key` is `path.resolve(cron.store)` — the
20//! legacy `cron/jobs.json` path used purely as a per-store partition key;
21//! at the pin no such file exists, and neither does a `cron/runs/*.jsonl`
22//! run log (that shape is what `openclaw doctor --fix` imports FROM).
23//! * **Claude Code** — has no run store at all. A `CronCreate` fire is an
24//! ordinary turn inside the session that created the job, so `runs.list` /
25//! `runs.get` refuse for `claude-code` rather than inventing a fire record
26//! from turns. See [`RUN_HARNESSES`].
27//!
28//! Nothing here writes, claims, retries, or prunes. Every store is opened
29//! `SQLITE_OPEN_READ_ONLY` — these are live databases owned by a running
30//! scheduler.
31//!
32//! **Status words are the harness's own.** Hermes says `completed`/`failed`,
33//! OpenClaw says `ok`/`error`; renaming either onto a shared vocabulary would
34//! discard the distinction Hermes draws between `failed` (a terminal result
35//! it wrote) and `unknown` (an attempt whose owner died before writing one).
36//!
37//! **Delivery (ORCH-13).** Where a fire's output went is read from each
38//! harness's own delivery record:
39//!
40//! * **OpenClaw** writes it onto the run-log row itself — `delivery_status`,
41//! `delivery_error`, `delivered` — and declares the destination on the job
42//! (`cron_jobs.delivery_channel` / `delivery_to`), so the row's `target` is
43//! joined from there: the run log records the OUTCOME, never the address.
44//! * **Hermes** keeps a separate `delivery_obligations` ledger inside
45//! `state.db` (`gateway/delivery_ledger.py`), keyed by the CONVERSATION's
46//! `session_key` and the platform surface — not by job or fire. So a fire is
47//! matched to an obligation the way [`join_hermes_session`] matches a
48//! session: by the fire's own `[claimed_at, finished_at]` window, on the
49//! fire's own surface. See [`hermes_delivery`] for the two questions asked,
50//! in order, and for why an unanchored ledger instant answers `None`.
51//!
52//! `None` stays honest: a fire whose delivery nothing recorded says so rather
53//! than borrowing a neighbouring fire's outcome.
54
55use std::path::{Path, PathBuf};
56
57use serde::{Deserialize, Serialize};
58use serde_json::{Map, Value};
59
60use crate::{HarnessHomes, HarnessId, Result};
61
62/// Harnesses that keep a run store at all. Every other harness answers
63/// `runs.list` / `runs.get` with `UnsupportedAction`, never an empty list —
64/// an absent store and an empty history are different answers.
65pub const RUN_HARNESSES: &[&str] = &[
66 HarnessId::HERMES,
67 HarnessId::OPENCLAW,
68 HarnessId::ORCHESTRATOR,
69];
70
71/// Hard stop on how far a compression chain is followed from a fire's own
72/// session to the readable tip. Hermes chains are short; a cycle in a
73/// corrupted store must not spin.
74/// One fire of one scheduled job, projected onto the uniform Domain 11 row.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct HarnessRun {
77 /// Harness-native run id: Hermes's `executions.id` (a uuid hex),
78 /// OpenClaw's `run_id` — or `<job_id>#<seq>` when a run-log row predates
79 /// run ids, since the `(job_id, seq)` pair is that store's own key.
80 pub id: String,
81 /// Owning harness.
82 pub harness: String,
83 /// The scheduled job this fire belongs to.
84 pub job_id: String,
85 /// The harness's own outcome word: Hermes `claimed | running | completed
86 /// | failed | unknown`, OpenClaw `ok | error | skipped`.
87 pub status: String,
88 /// When the scheduler claimed the fire. Hermes only — OpenClaw's run log
89 /// is written once, at finish, and records no claim.
90 pub claimed_at: Option<String>,
91 /// When the fire began executing.
92 pub started_at: Option<String>,
93 /// When the fire reached a terminal state.
94 pub finished_at: Option<String>,
95 /// The failure the harness recorded, verbatim.
96 pub error: Option<String>,
97 /// The session this fire opened, when it is recoverable: OpenClaw records
98 /// it on the row; Hermes does not, so it is recovered by matching
99 /// `cron_<job_id>_<YYYYMMDD_HHMMSS>` session ids inside the fire's own
100 /// window (see [`join_hermes_session`]). `None` means no session is
101 /// recoverable — never a guess.
102 pub session_id: Option<String>,
103 /// Where this fire's output went, when the harness recorded a delivery
104 /// for it. `None` means nothing in the harness's delivery record matches
105 /// this fire — never that the delivery failed.
106 pub delivery: Option<RunDelivery>,
107}
108
109/// A fire's delivery outcome (ORCH-13).
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct RunDelivery {
112 /// Where the output was addressed: Hermes's obligation surface
113 /// `<platform>:<chat_id>[:<thread_id>]`, or OpenClaw's job-declared
114 /// `<channel>[:<to>]`.
115 pub target: Option<String>,
116 /// The harness's own state word: Hermes `pending | attempting |
117 /// delivered | failed | abandoned`, OpenClaw `delivery_status`.
118 pub state: Option<String>,
119 /// Delivery attempts recorded. Hermes only — OpenClaw's run log counts
120 /// no attempts.
121 pub attempts: Option<u64>,
122 /// The last delivery failure, verbatim.
123 pub last_error: Option<String>,
124 /// When the harness stamped the delivery as done. Hermes only: it is the
125 /// obligation's `updated_at` on a `delivered` row (the ledger writes no
126 /// separate delivered-at column). OpenClaw's run log records `delivered`
127 /// as a flag with no instant of its own, so it stays empty there.
128 pub delivered_at: Option<String>,
129}
130
131impl HarnessRun {
132 /// The observed row of a typed orchestration [`Fire`] (`docs/ONTOLOGY.md` §2.7):
133 /// the join to its session and delivery is the caller's, as it is for the
134 /// ledger read, so `runs list` and the orchestration never disagree about a fire.
135 pub fn from_fire(
136 harness: &str,
137 fire: &supercode_interchange::orchestration::Fire,
138 session_id: Option<String>,
139 delivery: Option<RunDelivery>,
140 ) -> Self {
141 Self {
142 id: fire.id.clone(),
143 harness: harness.into(),
144 job_id: fire.job_id.clone(),
145 status: fire.status.hermes_word().to_string(),
146 claimed_at: Some(fire.claimed_at.clone()),
147 started_at: fire.started_at.clone(),
148 finished_at: fire.finished_at.clone(),
149 error: fire.error.clone(),
150 session_id: session_id.or_else(|| fire.session_id.clone()),
151 delivery,
152 }
153 }
154}
155
156/// One store the listing consulted, and what it found there.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct RunSource {
159 /// Harness the store belongs to.
160 pub harness: String,
161 /// Absolute path consulted.
162 pub path: PathBuf,
163 /// `read` | `absent_store` | `unreadable`.
164 pub state: String,
165 /// Hermes profile home this ledger belongs to.
166 pub profile: Option<String>,
167 /// Why a store is `unreadable`.
168 pub detail: Option<String>,
169}
170
171impl RunSource {
172 fn store(harness: &str, path: PathBuf, state: &str, profile: Option<String>) -> Self {
173 Self {
174 harness: harness.to_string(),
175 path,
176 state: state.to_string(),
177 profile,
178 detail: None,
179 }
180 }
181}
182
183/// Result of a `runs.list`: the rows plus every store that was consulted.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct RunsListing {
186 /// Uniform rows, harness-major then store order, newest fire first
187 /// within a store.
188 pub runs: Vec<HarnessRun>,
189 /// Stores consulted, including the ones that were absent.
190 pub sources: Vec<RunSource>,
191}
192
193/// Filters for a run-history read.
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(default)]
196pub struct RunsQuery {
197 /// Restrict to one harness. Absent means every harness in
198 /// [`RUN_HARNESSES`].
199 pub harness: Option<String>,
200 /// Restrict to one job's fires.
201 pub job: Option<String>,
202 /// Cap on rows. Applied per store as the read's own `LIMIT` (so a long
203 /// history is never fully materialized) and again to the merged listing.
204 pub limit: Option<usize>,
205 /// Storage roots to read.
206 pub homes: HarnessHomes,
207}
208
209/// Whether `harness` keeps a run store.
210pub fn supports_runs(harness: &str) -> bool {
211 RUN_HARNESSES.contains(&harness)
212}
213
214/// Read every fire the query selects.
215///
216/// Read-only: every store is opened `SQLITE_OPEN_READ_ONLY`, and no fire is
217/// claimed, retried, or pruned.
218pub fn list_runs(query: &RunsQuery) -> Result<RunsListing> {
219 let (rows, sources) = collect(query);
220 let mut runs: Vec<HarnessRun> = rows.into_iter().map(|(run, _)| run).collect();
221 if let Some(limit) = query.limit {
222 runs.truncate(limit);
223 }
224 Ok(RunsListing { runs, sources })
225}
226
227/// Read one fire by harness and id, with the verbatim native record beside
228/// the uniform row. `Ok(None)` means the harness's stores hold no such run.
229pub fn get_run(
230 harness: &str,
231 id: &str,
232 homes: &HarnessHomes,
233) -> Result<Option<(HarnessRun, Value)>> {
234 let (rows, _) = collect(&RunsQuery {
235 harness: Some(harness.to_string()),
236 homes: homes.clone(),
237 ..RunsQuery::default()
238 });
239 Ok(rows.into_iter().find(|(run, _)| run.id == id))
240}
241
242/// Every store the query selects, in harness-major order, each row paired
243/// with the harness's own record so `get` never re-reads (and so the two
244/// verbs can never disagree about a fire).
245fn collect(query: &RunsQuery) -> (Vec<(HarnessRun, Value)>, Vec<RunSource>) {
246 let mut rows = Vec::new();
247 let mut sources = Vec::new();
248 let wanted = query.harness.as_deref();
249 if wanted.is_none_or(|harness| harness == HarnessId::HERMES) {
250 collect_hermes(query, &mut rows, &mut sources);
251 }
252 if wanted.is_none_or(|harness| harness == HarnessId::OPENCLAW) {
253 collect_openclaw(query, &mut rows, &mut sources);
254 }
255 if wanted.is_none_or(|harness| harness == HarnessId::ORCHESTRATOR) {
256 collect_hermes_shaped(
257 HarnessId::ORCHESTRATOR,
258 orchestrator_ledgers(&query.homes),
259 query,
260 &mut rows,
261 &mut sources,
262 );
263 }
264 (rows, sources)
265}
266
267// ---------------------------------------------------------------------------
268// Hermes — `cron/executions.db`, joined to `state.db` sessions
269// ---------------------------------------------------------------------------
270
271/// One Hermes execution ledger, with the profile home it belongs to and the
272/// `state.db` whose sessions its fires opened.
273struct HermesLedger {
274 executions: PathBuf,
275 sessions: PathBuf,
276 /// The job store beside this ledger. A fire's delivery SURFACE is
277 /// declared on the job, not on the execution row, so the obligation
278 /// match (ORCH-13) needs it.
279 jobs: PathBuf,
280 profile: Option<String>,
281}
282
283/// Every `cron/executions.db` a Hermes install can hold.
284///
285/// A Hermes profile home IS a full HERMES_HOME (`hermes_constants.get_hermes_home`
286/// resolves the context-local profile override first), so the root home and
287/// every `profiles/<name>/` carry their own ledger AND their own `state.db`.
288/// A profile that has no `state.db` of its own falls back to the root store,
289/// where its rows carry `profile_name = <name>`.
290fn hermes_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
291 // `HarnessHomes::hermes` addresses `state.db`; the cron store is its
292 // sibling under the same HERMES_HOME.
293 let root = homes
294 .hermes
295 .parent()
296 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
297 let mut ledgers = vec![HermesLedger {
298 executions: root.join("cron/executions.db"),
299 sessions: homes.hermes.clone(),
300 jobs: root.join("cron/jobs.json"),
301 profile: None,
302 }];
303 if let Ok(entries) = std::fs::read_dir(root.join("profiles")) {
304 let mut found: Vec<HermesLedger> = entries
305 .flatten()
306 .filter(|entry| entry.path().is_dir())
307 .map(|entry| {
308 let home = entry.path();
309 let own = home.join("state.db");
310 HermesLedger {
311 executions: home.join("cron/executions.db"),
312 sessions: if own.is_file() {
313 own
314 } else {
315 homes.hermes.clone()
316 },
317 jobs: home.join("cron/jobs.json"),
318 profile: Some(entry.file_name().to_string_lossy().into_owned()),
319 }
320 })
321 .collect();
322 found.sort_by(|left, right| left.profile.cmp(&right.profile));
323 ledgers.extend(found);
324 }
325 ledgers
326}
327
328/// Every ledger an orchestrator home holds.
329///
330/// Identical in shape to [`hermes_ledgers`] because the folder is: each
331/// profile folder is a complete home with its own `cron/executions.db`,
332/// `cron/jobs.json` and `state.db` (`docs/ORCHESTRATOR-IR.md` §6). Bindings
333/// and obligations live in the profile's own store, so — unlike Hermes's
334/// multiplexed gateway — there is no fallback to a root store.
335fn orchestrator_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
336 crate::orchestrator_profile_dirs(&homes.orchestrator)
337 .into_iter()
338 .map(|(name, dir)| HermesLedger {
339 executions: dir.join("cron/executions.db"),
340 sessions: dir.join("state.db"),
341 jobs: dir.join("cron/jobs.json"),
342 profile: (name != "default").then_some(name),
343 })
344 .collect()
345}
346
347const HERMES_EXECUTION_COLUMNS: &[&str] = &[
348 "id",
349 "job_id",
350 "source",
351 "process_id",
352 "pid",
353 "process_started_at",
354 "status",
355 "claimed_at",
356 "started_at",
357 "finished_at",
358 "error",
359];
360
361fn collect_hermes(
362 query: &RunsQuery,
363 rows: &mut Vec<(HarnessRun, Value)>,
364 sources: &mut Vec<RunSource>,
365) {
366 collect_hermes_shaped(
367 HarnessId::HERMES,
368 hermes_ledgers(&query.homes),
369 query,
370 rows,
371 sources,
372 );
373}
374
375/// Every fire of a set of Hermes-SHAPED ledgers, as the orchestration codec
376/// reads the home: Hermes and the orchestrator keep the same `executions`
377/// table and `delivery_obligations` ledger, so one compile serves both and
378/// each ledger's fires are projected through [`HarnessRun::from_fire`] —
379/// newest-claimed first, the ledger's own order. The fire's session and
380/// delivery are the codec's derived links (`Fire.session_id`,
381/// `Fire.obligation_id`).
382fn collect_hermes_shaped(
383 harness: &str,
384 ledgers: Vec<HermesLedger>,
385 query: &RunsQuery,
386 rows: &mut Vec<(HarnessRun, Value)>,
387 sources: &mut Vec<RunSource>,
388) {
389 use supercode_interchange::orchestration::codec::{from_hermes, load_home, Flavor};
390 let loaded = match harness {
391 HarnessId::HERMES => {
392 let home = query
393 .homes
394 .hermes
395 .parent()
396 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
397 from_hermes(&home)
398 }
399 _ => load_home(&query.homes.orchestrator, Flavor::Orchestrator),
400 };
401 let loaded = match loaded {
402 Ok(loaded) => loaded,
403 Err(error) => {
404 for ledger in ledgers {
405 let state = if ledger.executions.is_file() {
406 "unreadable"
407 } else {
408 "absent_store"
409 };
410 sources.push(RunSource {
411 detail: (state == "unreadable").then(|| error.to_string()),
412 ..RunSource::store(harness, ledger.executions, state, ledger.profile)
413 });
414 }
415 return;
416 }
417 };
418 for ledger in ledgers {
419 if !ledger.executions.is_file() {
420 sources.push(RunSource::store(
421 harness,
422 ledger.executions,
423 "absent_store",
424 ledger.profile,
425 ));
426 continue;
427 }
428 let name = ledger.profile.clone().unwrap_or_else(|| "default".into());
429 let Some(profile) = loaded.orchestration.profiles.get(&name) else {
430 sources.push(RunSource::store(
431 harness,
432 ledger.executions,
433 "absent_store",
434 ledger.profile,
435 ));
436 continue;
437 };
438 sources.push(RunSource::store(
439 harness,
440 ledger.executions,
441 "read",
442 ledger.profile,
443 ));
444 let mut fires: Vec<_> = profile
445 .fires
446 .iter()
447 .filter(|fire| query.job.as_deref().is_none_or(|job| job == fire.job_id))
448 .collect();
449 fires.sort_by(|a, b| {
450 b.claimed_at
451 .cmp(&a.claimed_at)
452 .then_with(|| b.id.cmp(&a.id))
453 });
454 if let Some(limit) = query.limit {
455 fires.truncate(limit);
456 }
457 for fire in fires {
458 let delivery = fire
459 .obligation_id
460 .as_deref()
461 .and_then(|id| obligation_delivery(&loaded, id));
462 let native: Map<String, Value> = HERMES_EXECUTION_COLUMNS
463 .iter()
464 .map(|c| (*c).to_string())
465 .zip(supercode_interchange::orchestration::codec::decode::encode_fire_row(fire))
466 .collect();
467 rows.push((
468 HarnessRun::from_fire(harness, fire, None, delivery),
469 Value::Object(native),
470 ));
471 }
472 }
473}
474
475/// The delivery an obligation records, as the run shows it.
476fn obligation_delivery(
477 loaded: &supercode_interchange::orchestration::codec::LoadedHome,
478 id: &str,
479) -> Option<RunDelivery> {
480 let obligation = loaded
481 .orchestration
482 .profiles
483 .values()
484 .flat_map(|p| p.obligations.iter())
485 .find(|o| o.id == id)?;
486 let platform = obligation.target.platform.clone().unwrap_or_default();
487 let chat_id = obligation.target.chat_id.clone().unwrap_or_default();
488 Some(RunDelivery {
489 target: Some(
490 match obligation
491 .target
492 .thread_id
493 .as_deref()
494 .filter(|t| !t.is_empty())
495 {
496 Some(thread) => format!("{platform}:{chat_id}:{thread}"),
497 None => format!("{platform}:{chat_id}"),
498 },
499 ),
500 state: Some(obligation.state.hermes_word().to_string()),
501 attempts: Some(obligation.attempts),
502 last_error: obligation.last_error.clone().filter(|e| !e.is_empty()),
503 delivered_at: obligation
504 .delivered_at
505 .as_deref()
506 .and_then(|at| at.parse::<f64>().ok())
507 .map(|seconds| crate::sidecar::ms_to_rfc3339((seconds * 1000.0) as i64)),
508 })
509}
510
511fn openclaw_state_db(homes: &HarnessHomes) -> PathBuf {
512 homes.openclaw.join("state/openclaw.sqlite")
513}
514
515/// OpenClaw's run logs as the orchestration codec reads the store: every
516/// profile's fires, newest first (`ts`, then id), the store's own status word
517/// and delivery columns from the fire's residue, the job's delivery channel
518/// as the target.
519fn collect_openclaw(
520 query: &RunsQuery,
521 rows: &mut Vec<(HarnessRun, Value)>,
522 sources: &mut Vec<RunSource>,
523) {
524 use supercode_interchange::orchestration::codec::{from_openclaw, openclaw::encode_fire_row};
525 let state_db = openclaw_state_db(&query.homes);
526 if !state_db.is_file() {
527 sources.push(RunSource::store(
528 HarnessId::OPENCLAW,
529 state_db,
530 "absent_store",
531 None,
532 ));
533 return;
534 }
535 let loaded = match from_openclaw(&query.homes.openclaw) {
536 Ok(loaded) => loaded,
537 Err(error) => {
538 sources.push(RunSource {
539 detail: Some(error.to_string()),
540 ..RunSource::store(HarnessId::OPENCLAW, state_db, "unreadable", None)
541 });
542 return;
543 }
544 };
545 sources.push(RunSource::store(
546 HarnessId::OPENCLAW,
547 state_db,
548 "read",
549 None,
550 ));
551 let text = |fire: &supercode_interchange::orchestration::Fire, key: &str| {
552 fire.residue
553 .0
554 .get(key)
555 .and_then(Value::as_str)
556 .map(str::to_string)
557 };
558 let mut fires: Vec<_> = loaded
559 .orchestration
560 .profiles
561 .values()
562 .flat_map(|profile| profile.fires.iter().map(move |fire| (profile, fire)))
563 .filter(|(_, fire)| query.job.as_deref().is_none_or(|job| job == fire.job_id))
564 .collect();
565 fires.sort_by(|(_, a), (_, b)| {
566 b.finished_at
567 .cmp(&a.finished_at)
568 .then_with(|| b.id.cmp(&a.id))
569 });
570 if let Some(limit) = query.limit {
571 fires.truncate(limit);
572 }
573 for (profile, fire) in fires {
574 let target = profile.jobs.get(&fire.job_id).and_then(|job| {
575 let delivery = job.residue.0.get("__delivery")?.as_object()?;
576 let word = |k: &str| {
577 delivery
578 .get(k)
579 .and_then(Value::as_str)
580 .filter(|v| !v.is_empty())
581 .map(str::to_string)
582 };
583 match (word("channel"), word("to")) {
584 (Some(channel), Some(to)) => Some(format!("{channel}:{to}")),
585 (Some(only), None) | (None, Some(only)) => Some(only),
586 (None, None) => None,
587 }
588 });
589 let delivery = openclaw_delivery(
590 target,
591 text(fire, "delivery_status"),
592 text(fire, "delivery_error"),
593 fire.residue.0.get("delivered").and_then(Value::as_i64),
594 );
595 let store_key = text(fire, "store_key").unwrap_or_default();
596 rows.push((
597 HarnessRun {
598 id: fire.id.clone(),
599 harness: HarnessId::OPENCLAW.into(),
600 job_id: fire.job_id.clone(),
601 status: text(fire, "status").unwrap_or_default(),
602 claimed_at: None,
603 started_at: fire.started_at.clone(),
604 finished_at: fire.finished_at.clone(),
605 error: fire.error.clone().filter(|e| !e.is_empty()),
606 session_id: fire.session_id.clone().filter(|s| !s.is_empty()),
607 delivery,
608 },
609 Value::Object(encode_fire_row(fire, None, &store_key)),
610 ));
611 }
612}
613
614fn openclaw_delivery(
615 target: Option<String>,
616 status: Option<String>,
617 error: Option<String>,
618 delivered: Option<i64>,
619) -> Option<RunDelivery> {
620 let status = status.filter(|status| !status.is_empty());
621 let error = error.filter(|error| !error.is_empty());
622 if status.is_none() && error.is_none() && delivered.is_none() {
623 return None;
624 }
625 Some(RunDelivery {
626 target,
627 state: status.or_else(|| {
628 delivered.map(|delivered| {
629 if delivered == 0 {
630 "not-delivered".to_string()
631 } else {
632 "delivered".to_string()
633 }
634 })
635 }),
636 // OpenClaw's run log counts no delivery attempts, and stamps no
637 // instant on `delivered` — the flag rides the finish record.
638 attempts: None,
639 last_error: error,
640 delivered_at: None,
641 })
642}