Skip to main content

supercode_harness/
jobs_control.rs

1//! Controlled-tier scheduled jobs (Domain 11, concept 6) — the FIRST
2//! controlled-tier noun, and the shape the rest of wave 2 copies.
3//!
4//! Charter (`docs/plans/orchestration-domain-11-2026-09-02.md` §0.4):
5//! **supercode never runs a cron engine.** Every mutation here is the
6//! harness's OWN verb, executed as a subprocess, with supercode acting as the
7//! uniform client:
8//!
9//! * **Hermes** — `hermes cron create | edit | pause | resume | run | remove`
10//!   with `HERMES_HOME` in the environment (a profile IS a HERMES_HOME:
11//!   upstream `hermes_cli/profiles.py` spawns profile work with
12//!   `HERMES_HOME=<home>/profiles/<name>`).
13//! * **OpenClaw** — `openclaw cron add | edit | disable | enable | run | rm`.
14//!   Every one of these goes through the Gateway websocket, so the endpoint
15//!   and credential are resolved from OPENCLAW's OWN config
16//!   (`<state dir>/openclaw.json`, pointers `/gateway/remote/url`,
17//!   `/gateway/port`, `/gateway/auth/token`) through the very same
18//!   [`crate::RuntimeConnectLaunch`] the connect descriptor uses — never from
19//!   supercode's own config, and never from an inherited environment variable.
20//! * **The orchestrator** — its own package (ORC-13). The write door is the
21//!   daemon's local socket while it is up and `node bin/orchestrator.mjs
22//!   <op> …` when it is down, both landing in the SAME `applyOperator` →
23//!   reducer → `save()` path inside `sdk/orchestrator`, which owns the
24//!   folder's byte-stability and its residue rules
25//!   (`docs/ORCHESTRATOR-IR.md` §4.6, §6). supercode writes no file of that
26//!   folder itself; [`crate::orchestrator_door`] is the uniform client.
27//! * **Claude Code** — refused. Its jobs are session-scoped runtime state
28//!   created by the model inside a session (`CronCreate`); the harness
29//!   publishes no verb a client can call.
30//!
31//! Three rules the whole tier inherits:
32//!
33//! 1. **The harness's answer is the answer.** After the verb exits 0 the row
34//!    is re-read through the ORCH-7 loader ([`crate::jobs`]) and returned. A
35//!    non-zero exit surfaces the harness's own stderr as the error — never a
36//!    silent success, never a supercode-invented row.
37//! 2. **The command is narrated.** Every outcome carries `ran`: the exact
38//!    argv that was executed, with any credential rendered as `<redacted>`.
39//!    Tokens are never printed, logged, or stored.
40//! 3. **A field the harness has no verb for is refused**
41//!    ([`JobControlError::Unsupported`] → `UnsupportedAction`), never dropped.
42
43use std::collections::BTreeSet;
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48
49pub(crate) use crate::harness_command::shell_quote;
50pub(crate) use crate::harness_command::HarnessCommand;
51use crate::{HarnessHomes, HarnessId, ScheduledJob};
52
53pub use crate::harness_command::{HERMES_BIN_ENV, OPENCLAW_BIN_ENV};
54
55/// Harnesses whose scheduled jobs supercode can MUTATE through their own CLI
56/// verb. Strictly narrower than [`crate::jobs::JOB_HARNESSES`]: Claude Code is
57/// readable but not controllable.
58pub const CONTROLLED_JOB_HARNESSES: &[&str] = &[
59    HarnessId::HERMES,
60    HarnessId::OPENCLAW,
61    HarnessId::ORCHESTRATOR,
62];
63
64/// Why Claude Code refuses every mutating job verb.
65pub const CLAUDE_CODE_REFUSAL: &str =
66    "claude-code scheduled jobs are session-scoped runtime state: they are created by the model \
67     inside a session (`CronCreate`) and restored on resume. Claude Code publishes no harness verb \
68     a client can call, so supercode refuses rather than inventing one";
69
70/// One uniform mutating verb.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum JobVerb {
74    /// Create a new scheduled job.
75    Create,
76    /// Patch an existing job's fields.
77    Update,
78    /// Stop the scheduler from firing a job.
79    Pause,
80    /// Let the scheduler fire a job again.
81    Resume,
82    /// Fire a job now, out of schedule.
83    Run,
84    /// Remove a job.
85    Delete,
86}
87
88impl JobVerb {
89    /// Uniform spelling used in the RPC method and in outcomes.
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Create => "create",
93            Self::Update => "update",
94            Self::Pause => "pause",
95            Self::Resume => "resume",
96            Self::Run => "run",
97            Self::Delete => "delete",
98        }
99    }
100
101    /// Whether the verb needs an existing job id.
102    const fn needs_id(self) -> bool {
103        !matches!(self, Self::Create)
104    }
105}
106
107/// Uniform firing rule for a create/update.
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct JobScheduleSpec {
111    /// `interval` | `cron` | `once`.
112    pub kind: String,
113    /// Interval length, for `kind = "interval"`.
114    pub minutes: Option<f64>,
115    /// Cron expression, for `kind = "cron"`.
116    pub expr: Option<String>,
117    /// Absolute instant, for `kind = "once"`.
118    pub run_at: Option<String>,
119}
120
121/// Uniform payload for a create/update.
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(default)]
124pub struct JobPayloadSpec {
125    /// `prompt` | `system_event` | `command` | `script`.
126    pub kind: String,
127    /// The prompt, event, command line, or script the fire carries.
128    pub text: Option<String>,
129}
130
131/// Uniform delivery for a create/update.
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(default, deny_unknown_fields)]
134pub struct JobDeliverSpec {
135    /// Hermes `deliver` grammar (`origin` | `local` | `<platform>`), or an
136    /// OpenClaw delivery mode (`announce` | `webhook` | `none`).
137    pub target: Option<String>,
138    /// Chat / destination the delivery is addressed to.
139    pub chat_id: Option<String>,
140}
141
142/// One mutating request, in the uniform Domain 11 vocabulary.
143#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
144pub struct JobMutation {
145    /// Harness that owns the job.
146    pub harness: String,
147    /// Job id, for every verb but `create`.
148    #[serde(default)]
149    pub id: Option<String>,
150    /// Human-friendly job name.
151    #[serde(default)]
152    pub name: Option<String>,
153    /// When the job fires.
154    #[serde(default)]
155    pub schedule: Option<JobScheduleSpec>,
156    /// What fires.
157    #[serde(default)]
158    pub payload: Option<JobPayloadSpec>,
159    /// OpenClaw `sessionTarget` (`main` | `isolated`).
160    #[serde(default)]
161    pub session_target: Option<String>,
162    /// Where the fire's output goes.
163    #[serde(default)]
164    pub deliver: Option<JobDeliverSpec>,
165    /// Hermes profile name / OpenClaw agent id.
166    #[serde(default)]
167    pub profile: Option<String>,
168    /// Skills the fire loads. On `update` the set replaces the job's; an empty
169    /// set clears it.
170    #[serde(default)]
171    pub skills: Option<Vec<String>>,
172    /// Directory the fire runs from.
173    #[serde(default)]
174    pub workdir: Option<String>,
175    /// Model the job is pinned to.
176    #[serde(default)]
177    pub model: Option<String>,
178    /// Provider the pinned model is served by.
179    #[serde(default)]
180    pub provider: Option<String>,
181    /// How many times the job fires before it is done.
182    #[serde(default)]
183    pub repeat: Option<u32>,
184    /// Storage roots, so an isolated home is addressed the same way the
185    /// read side addresses it.
186    #[serde(default)]
187    pub homes: HarnessHomes,
188}
189
190/// What one mutation did, with the harness's own row read back afterwards.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct JobMutationOutcome {
193    /// Harness that ran the verb.
194    pub harness: String,
195    /// Uniform verb that was asked for.
196    pub verb: String,
197    /// The exact harness command that ran, credentials redacted.
198    pub ran: String,
199    /// Affected job id.
200    pub id: String,
201    /// The job as the harness's own store reports it AFTER the verb.
202    /// Absent for `delete`.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub job: Option<ScheduledJob>,
205    /// `true` on a successful `delete`.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub deleted: Option<bool>,
208}
209
210/// Why a mutation could not be performed.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum JobControlError {
213    /// The harness has no verb for what was asked (refused, never faked).
214    Unsupported(String),
215    /// The request itself is incoherent.
216    Invalid(String),
217    /// The harness verb ran and failed; the message carries its stderr.
218    Failed(String),
219}
220
221impl std::fmt::Display for JobControlError {
222    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
225                formatter.write_str(message)
226            }
227        }
228    }
229}
230
231impl std::error::Error for JobControlError {}
232
233type Result<T> = std::result::Result<T, JobControlError>;
234
235/// Whether `harness` can have its scheduled jobs mutated at all.
236pub fn supports_job_control(harness: &str) -> bool {
237    CONTROLLED_JOB_HARNESSES.contains(&harness)
238}
239
240pub fn harness_program(harness: &str) -> Result<String> {
241    crate::harness_command::harness_program(harness).map_err(|detail| {
242        JobControlError::Unsupported(detail.unwrap_or_else(|| unsupported_harness(harness)))
243    })
244}
245
246fn unsupported_harness(harness: &str) -> String {
247    if harness == HarnessId::CLAUDE_CODE {
248        return CLAUDE_CODE_REFUSAL.to_string();
249    }
250    format!(
251        "`{harness}` has no mutable scheduled jobs; mutating job verbs are supported for: {}",
252        CONTROLLED_JOB_HARNESSES.join(", ")
253    )
254}
255
256/// `HERMES_HOME` for this request: the profile's own home when one is named
257/// (upstream treats a profile as a full HERMES_HOME), else the install root.
258pub(crate) fn hermes_home(mutation: &JobMutation) -> PathBuf {
259    // `HarnessHomes::hermes` addresses `state.db`; HERMES_HOME is its parent,
260    // the same derivation the read side uses.
261    let root = mutation
262        .homes
263        .hermes
264        .parent()
265        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
266    match mutation.profile.as_deref() {
267        Some(profile) => root.join("profiles").join(profile),
268        None => root,
269    }
270}
271
272/// Resolve OpenClaw's gateway endpoint and credential from OPENCLAW's own
273/// config, through the registry's connect descriptor.
274///
275/// The pointers are never re-spelled here: the descriptor
276/// (`/gateway/remote/url` → `/gateway/port` → the documented default, auth at
277/// `/gateway/auth/token`) is taken from the compiled registry and only its
278/// `config_path` is re-anchored onto the state dir the caller addressed, so an
279/// isolated home resolves ITS token and the default home resolves the real
280/// one. supercode's own config is never consulted.
281fn openclaw_connection(homes: &HarnessHomes) -> Result<crate::ResolvedRuntimeConnection> {
282    let registry = crate::harness_support_registry();
283    let descriptor = registry
284        .harnesses
285        .iter()
286        .find(|descriptor| descriptor.id.as_str() == HarnessId::OPENCLAW)
287        .ok_or_else(|| {
288            JobControlError::Unsupported("the registry has no openclaw descriptor".into())
289        })?;
290    let connect = descriptor.runtime.connect_launch.as_ref().ok_or_else(|| {
291        JobControlError::Unsupported(
292            "openclaw has no registered connect-mode launch, so its gateway cannot be located"
293                .into(),
294        )
295    })?;
296    let mut connect = connect.clone();
297    connect.config_path = homes
298        .openclaw
299        .join("openclaw.json")
300        .to_string_lossy()
301        .into_owned();
302    connect.resolve(Path::new("/")).map_err(|error| {
303        JobControlError::Unsupported(format!(
304            "openclaw's gateway endpoint could not be resolved from its own config: {error}"
305        ))
306    })
307}
308
309/// Perform one mutation: translate to the harness's own verb, run it, then
310/// re-read the row through the ORCH-7 loader.
311pub fn mutate(verb: JobVerb, mutation: &JobMutation) -> Result<JobMutationOutcome> {
312    if !supports_job_control(&mutation.harness) {
313        return Err(JobControlError::Unsupported(unsupported_harness(
314            &mutation.harness,
315        )));
316    }
317    if verb.needs_id() && mutation.id.as_deref().unwrap_or("").trim().is_empty() {
318        return Err(JobControlError::Invalid(format!(
319            "`jobs.{}` needs the job id to act on",
320            verb.as_str()
321        )));
322    }
323    if matches!(verb, JobVerb::Create) && mutation.schedule.is_none() {
324        return Err(JobControlError::Invalid(
325            "`jobs.create` needs a schedule (interval, cron, or once)".into(),
326        ));
327    }
328    if mutation.harness != HarnessId::HERMES {
329        if let Some(field) = hermes_only_field(mutation) {
330            return Err(JobControlError::Unsupported(format!(
331                "{} has no job verb that sets `{field}`, so supercode refuses rather than dropping it",
332                mutation.harness
333            )));
334        }
335    }
336    // ORC-13: the orchestrator's verb is not a CLI subprocess but its own
337    // package's operator door, so it branches before the command table.
338    if mutation.harness == HarnessId::ORCHESTRATOR {
339        return orchestrator_mutate(verb, mutation);
340    }
341    let command = match mutation.harness.as_str() {
342        HarnessId::HERMES => hermes_command(verb, mutation)?,
343        HarnessId::OPENCLAW => openclaw_command(verb, mutation)?,
344        other => return Err(JobControlError::Unsupported(unsupported_harness(other))),
345    };
346    let ran = command.narrate();
347    let before = matches!(verb, JobVerb::Create).then(|| known_ids(mutation));
348    let stdout = command.run().map_err(JobControlError::Failed)?;
349    // Hermes exits 0 on a failed verb and says so on stdout ("Failed to update
350    // job: …", "Job not found: …"); that sentence is the answer, never the
351    // unchanged row a re-read would find.
352    if mutation.harness == HarnessId::HERMES {
353        if let Some(said) = hermes_failure(&stdout) {
354            return Err(JobControlError::Failed(format!(
355                "`{ran}` exited 0 but hermes said: {said}"
356            )));
357        }
358    }
359    let id = match (verb, before) {
360        (JobVerb::Create, Some(before)) => created_id(mutation, &before, &stdout, &ran)?,
361        _ => mutation.id.clone().unwrap_or_default(),
362    };
363    // The harness's own store is the answer: re-read, never echo the request.
364    let read = crate::jobs::get_job(&mutation.harness, &id, &mutation.homes).map_err(|error| {
365        JobControlError::Failed(format!(
366            "`{ran}` succeeded but the job store could not be re-read: {error}"
367        ))
368    })?;
369    match verb {
370        JobVerb::Delete => {
371            if read.is_some() {
372                return Err(JobControlError::Failed(format!(
373                    "`{ran}` reported success but `{id}` is still in {}'s job store",
374                    mutation.harness
375                )));
376            }
377            Ok(JobMutationOutcome {
378                harness: mutation.harness.clone(),
379                verb: verb.as_str().to_string(),
380                ran,
381                id,
382                job: None,
383                deleted: Some(true),
384            })
385        }
386        _ => {
387            let (job, _) = read.ok_or_else(|| {
388                JobControlError::Failed(format!(
389                    "`{ran}` reported success but `{}` has no job `{id}` afterwards",
390                    mutation.harness
391                ))
392            })?;
393            Ok(JobMutationOutcome {
394                harness: mutation.harness.clone(),
395                verb: verb.as_str().to_string(),
396                ran,
397                id,
398                job: Some(job),
399                deleted: None,
400            })
401        }
402    }
403}
404
405// ---------------------------------------------------------------------------
406// The orchestrator — its own package's operator door (ORC-13)
407// ---------------------------------------------------------------------------
408
409/// The orchestrator profile this mutation acts in: `--profile`, else the
410/// root folder, which IS the `default` profile (`docs/ORCHESTRATOR-IR.md` §6).
411fn orchestrator_profile(mutation: &JobMutation) -> &str {
412    mutation
413        .profile
414        .as_deref()
415        .map(str::trim)
416        .filter(|profile| !profile.is_empty())
417        .unwrap_or("default")
418}
419
420/// The uniform row translated onto the orchestrator's OWN job vocabulary
421/// (`docs/ORCHESTRATOR-IR.md` §2.6) — the same words its MCP tools and its
422/// chat commands use. A field the model has no home for is refused by name,
423/// never dropped.
424fn orchestrator_args(verb: JobVerb, mutation: &JobMutation) -> Result<Value> {
425    let mut args = serde_json::Map::new();
426    if let Some(id) = mutation.id.as_deref().filter(|id| !id.trim().is_empty()) {
427        args.insert("id".into(), Value::String(id.trim().to_string()));
428    }
429    if matches!(verb, JobVerb::Create | JobVerb::Update) {
430        if mutation.session_target.is_some() {
431            return Err(JobControlError::Unsupported(
432                "an orchestrator cron fire opens its own binding on the job's origin surface \
433                 (`docs/ORCHESTRATOR-IR.md` §4.3); the model has no session-target field, so \
434                 supercode refuses rather than dropping it"
435                    .into(),
436            ));
437        }
438        if let Some(name) = &mutation.name {
439            args.insert("name".into(), Value::String(name.clone()));
440        }
441        if let Some(schedule) = &mutation.schedule {
442            args.insert("schedule".into(), orchestrator_schedule(schedule)?);
443        }
444        if let Some(payload) = &mutation.payload {
445            match payload_kind(payload) {
446                "prompt" => {
447                    args.insert(
448                        "prompt".into(),
449                        Value::String(payload_text(payload)?.to_string()),
450                    );
451                }
452                other => {
453                    return Err(JobControlError::Unsupported(format!(
454                        "an orchestrator job carries a `prompt` — the fire opens a worker session \
455                         and sends it (§4.3); there is no `{other}` payload, so supercode refuses \
456                         rather than inventing one"
457                    )))
458                }
459            }
460        }
461        if let Some(deliver) = &mutation.deliver {
462            if let Some(target) = hermes_deliver(deliver) {
463                // The orchestrator's `deliver` grammar IS Hermes's
464                // (`origin | local | home | <platform>[:<chat_id>]`, §2.6).
465                args.insert("deliver".into(), Value::String(target));
466            }
467        }
468    } else if mutation.name.is_some()
469        || mutation.schedule.is_some()
470        || mutation.payload.is_some()
471        || mutation.deliver.is_some()
472        || mutation.session_target.is_some()
473    {
474        return Err(JobControlError::Invalid(format!(
475            "`jobs.{}` changes no fields; pass definition fields to `jobs.update`",
476            verb.as_str()
477        )));
478    }
479    Ok(Value::Object(args))
480}
481
482/// The uniform schedule in the orchestrator's typed form (§2.6).
483fn orchestrator_schedule(schedule: &JobScheduleSpec) -> Result<Value> {
484    match schedule.kind.as_str() {
485        "interval" => schedule
486            .minutes
487            .map(|minutes| serde_json::json!({"kind": "interval", "minutes": minutes}))
488            .ok_or_else(|| JobControlError::Invalid("an interval schedule needs `minutes`".into())),
489        "cron" => schedule
490            .expr
491            .as_deref()
492            .map(|expr| serde_json::json!({"kind": "cron", "expr": expr}))
493            .ok_or_else(|| JobControlError::Invalid("a cron schedule needs `expr`".into())),
494        "once" => schedule
495            .run_at
496            .as_deref()
497            .map(|run_at| serde_json::json!({"kind": "once", "run_at": run_at}))
498            .ok_or_else(|| JobControlError::Invalid("a once schedule needs `run_at`".into())),
499        other => Err(JobControlError::Invalid(format!(
500            "unknown schedule kind `{other}`; use interval, cron, or once"
501        ))),
502    }
503}
504
505/// One orchestrator job mutation: through the package's door, then re-read
506/// through the ORC-7 loader like every other harness's row.
507fn orchestrator_mutate(verb: JobVerb, mutation: &JobMutation) -> Result<JobMutationOutcome> {
508    let args = orchestrator_args(verb, mutation)?;
509    let root = mutation.homes.orchestrator.clone();
510    let profile = orchestrator_profile(mutation);
511    let op = format!("jobs.{}", verb.as_str());
512    let answer = crate::orchestrator_door::call(&root, &op, &args, profile).map_err(|error| {
513        match error {
514            // The package refused: its sentence is the answer, in the same
515            // shape a harness's stderr takes for the other two.
516            crate::orchestrator_door::DoorError::Refused(message) => {
517                JobControlError::Failed(message)
518            }
519            crate::orchestrator_door::DoorError::Failed(message) => {
520                JobControlError::Failed(message)
521            }
522        }
523    })?;
524    let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
525    let id = answer
526        .result
527        .pointer("/job_id")
528        .and_then(Value::as_str)
529        .map(str::to_string)
530        .or_else(|| mutation.id.clone())
531        .ok_or_else(|| JobControlError::Failed(format!("`{ran}` succeeded but named no job id")))?;
532    // The FOLDER is the answer, re-read through the same loader `jobs list`
533    // uses — never the door's echo of what it wrote.
534    let read = crate::jobs::get_job(&mutation.harness, &id, &mutation.homes).map_err(|error| {
535        JobControlError::Failed(format!(
536            "`{ran}` succeeded but the job store could not be re-read: {error}"
537        ))
538    })?;
539    match verb {
540        JobVerb::Delete => {
541            if read.is_some() {
542                return Err(JobControlError::Failed(format!(
543                    "`{ran}` reported success but `{id}` is still in the orchestrator's job store"
544                )));
545            }
546            Ok(JobMutationOutcome {
547                harness: mutation.harness.clone(),
548                verb: verb.as_str().to_string(),
549                ran,
550                id,
551                job: None,
552                deleted: Some(true),
553            })
554        }
555        _ => {
556            let (job, _) = read.ok_or_else(|| {
557                JobControlError::Failed(format!(
558                    "`{ran}` reported success but the orchestrator has no job `{id}` afterwards"
559                ))
560            })?;
561            Ok(JobMutationOutcome {
562                harness: mutation.harness.clone(),
563                verb: verb.as_str().to_string(),
564                ran,
565                id,
566                job: Some(job),
567                deleted: None,
568            })
569        }
570    }
571}
572
573/// Every job id the harness's store holds right now.
574fn known_ids(mutation: &JobMutation) -> BTreeSet<String> {
575    crate::jobs::list_jobs(&crate::jobs::JobsQuery {
576        harness: Some(mutation.harness.clone()),
577        homes: mutation.homes.clone(),
578        ..crate::jobs::JobsQuery::default()
579    })
580    .map(|listing| listing.jobs.into_iter().map(|job| job.id).collect())
581    .unwrap_or_default()
582}
583
584/// Identify the job the create verb just made: the id the harness's own store
585/// gained. When several appeared (a concurrent writer), the harness's stdout
586/// decides between them.
587fn created_id(
588    mutation: &JobMutation,
589    before: &BTreeSet<String>,
590    stdout: &str,
591    ran: &str,
592) -> Result<String> {
593    let after = known_ids(mutation);
594    let mut fresh: Vec<String> = after.difference(before).cloned().collect();
595    if fresh.len() == 1 {
596        return Ok(fresh.remove(0));
597    }
598    if let Some(named) = fresh.iter().find(|id| stdout.contains(id.as_str())) {
599        return Ok(named.clone());
600    }
601    // Last resort: an id the harness printed that the store now holds (a
602    // store that reuses an existing id, e.g. an idempotent declaration key).
603    if let Some(id) = stdout_id(stdout).filter(|id| after.contains(id)) {
604        return Ok(id);
605    }
606    // A harness that exits 0 on a failed create (Hermes prints "Failed to
607    // create job: …") said why on stdout; that is the answer to surface.
608    let said = stdout.trim();
609    Err(JobControlError::Failed(format!(
610        "`{ran}` reported success but {} gained {} job(s), so the new job cannot be identified{}",
611        mutation.harness,
612        fresh.len(),
613        if said.is_empty() {
614            String::new()
615        } else {
616            format!("; {} said: {said}", mutation.harness)
617        }
618    )))
619}
620
621/// An `id` field from a harness's JSON stdout, when it prints one.
622fn stdout_id(stdout: &str) -> Option<String> {
623    let value: Value = serde_json::from_str(stdout.trim()).ok()?;
624    for pointer in ["/id", "/job/id", "/jobId", "/job_id", "/result/id"] {
625        if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
626            return Some(id.to_string());
627        }
628    }
629    None
630}
631
632// ---------------------------------------------------------------------------
633// Hermes — `hermes cron …` over HERMES_HOME
634// ---------------------------------------------------------------------------
635
636/// Hermes's schedule argument: one positional string its own parser reads
637/// (`cron/jobs.py::parse_schedule` — `every 10m`, a cron expression, or an
638/// ISO instant for a one-shot).
639fn hermes_schedule(schedule: &JobScheduleSpec) -> Result<String> {
640    match schedule.kind.as_str() {
641        "interval" => schedule
642            .minutes
643            .map(|minutes| format!("every {}m", trim_float(minutes)))
644            .ok_or_else(|| JobControlError::Invalid("an interval schedule needs `minutes`".into())),
645        "cron" => schedule
646            .expr
647            .clone()
648            .ok_or_else(|| JobControlError::Invalid("a cron schedule needs `expr`".into())),
649        "once" => schedule
650            .run_at
651            .clone()
652            .ok_or_else(|| JobControlError::Invalid("a once schedule needs `run_at`".into())),
653        other => Err(JobControlError::Invalid(format!(
654            "unknown schedule kind `{other}`; use interval, cron, or once"
655        ))),
656    }
657}
658
659fn trim_float(value: f64) -> String {
660    if value.fract().abs() < f64::EPSILON {
661        format!("{}", value as i64)
662    } else {
663        format!("{value}")
664    }
665}
666
667/// Hermes's delivery argument, in hermes's own grammar
668/// (`origin | local | <platform> | <platform>:<chat_id>`).
669pub(crate) fn hermes_deliver(deliver: &JobDeliverSpec) -> Option<String> {
670    let target = deliver.target.as_deref()?.trim().to_string();
671    match deliver.chat_id.as_deref() {
672        Some(chat) if !target.contains(':') && !chat.trim().is_empty() => {
673            Some(format!("{target}:{}", chat.trim()))
674        }
675        _ => Some(target),
676    }
677}
678
679/// The first definition field only Hermes's own verbs can set
680/// (`hermes cron create|edit --skill --workdir --model --provider --repeat`).
681fn hermes_only_field(mutation: &JobMutation) -> Option<&'static str> {
682    [
683        ("skills", mutation.skills.is_some()),
684        ("workdir", mutation.workdir.is_some()),
685        ("model", mutation.model.is_some()),
686        ("provider", mutation.provider.is_some()),
687        ("repeat", mutation.repeat.is_some()),
688    ]
689    .into_iter()
690    .find_map(|(field, set)| set.then_some(field))
691}
692
693/// Hermes's own flags for the definition fields both `cron create` and
694/// `cron edit` take. An empty skill set on `edit` clears the job's skills.
695fn hermes_definition_args(verb: JobVerb, mutation: &JobMutation, command: &mut HarnessCommand) {
696    if let Some(skills) = &mutation.skills {
697        if skills.is_empty() && matches!(verb, JobVerb::Update) {
698            command.arg("--clear-skills");
699        }
700        for skill in skills {
701            command.args(["--skill", skill]);
702        }
703    }
704    if let Some(workdir) = &mutation.workdir {
705        command.args(["--workdir", workdir]);
706    }
707    if let Some(model) = &mutation.model {
708        command.args(["--model", model]);
709    }
710    if let Some(provider) = &mutation.provider {
711        command.args(["--provider", provider]);
712    }
713    if let Some(repeat) = mutation.repeat {
714        command.args(["--repeat", &repeat.to_string()]);
715    }
716}
717
718/// The line in which Hermes reports a failed cron verb, when it printed one.
719fn hermes_failure(stdout: &str) -> Option<&str> {
720    stdout
721        .lines()
722        .map(str::trim)
723        .find(|line| line.starts_with("Failed to ") || line.starts_with("Job not found"))
724}
725
726fn hermes_command(verb: JobVerb, mutation: &JobMutation) -> Result<HarnessCommand> {
727    if let Some(deliver) = &mutation.deliver {
728        if deliver
729            .target
730            .as_deref()
731            .is_none_or(|t| t.trim().is_empty())
732            && deliver.chat_id.is_some()
733        {
734            return Err(JobControlError::Invalid(
735                "a delivery needs a target; a chat id alone names no destination, so supercode \
736                 refuses rather than dropping it"
737                    .into(),
738            ));
739        }
740    }
741    if mutation.session_target.is_some() {
742        return Err(JobControlError::Unsupported(
743            "hermes cron fires always open their own `platform=cron` session; hermes has no \
744             session-target verb, so supercode refuses rather than dropping the field"
745                .into(),
746        ));
747    }
748    let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
749    command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
750    command.args(["cron"]);
751    let id = mutation.id.clone().unwrap_or_default();
752    match verb {
753        JobVerb::Create => {
754            command.arg("create");
755            if let Some(name) = &mutation.name {
756                command.args(["--name", name]);
757            }
758            if let Some(deliver) = mutation.deliver.as_ref().and_then(hermes_deliver) {
759                command.args(["--deliver", &deliver]);
760            }
761            if let Some(payload) = &mutation.payload {
762                if payload_kind(payload) == "script" {
763                    command.args(["--script", payload_text(payload)?]);
764                }
765            }
766            hermes_definition_args(verb, mutation, &mut command);
767            // The schedule is positional and must precede the prompt.
768            let schedule = hermes_schedule(
769                mutation
770                    .schedule
771                    .as_ref()
772                    .expect("create validates a schedule"),
773            )?;
774            command.arg(schedule);
775            if let Some(payload) = &mutation.payload {
776                match payload_kind(payload) {
777                    "prompt" => {
778                        command.arg(payload_text(payload)?);
779                    }
780                    "script" => {}
781                    other => return Err(hermes_payload_refusal(other)),
782                }
783            }
784        }
785        JobVerb::Update => {
786            command.args(["edit", &id]);
787            if let Some(schedule) = &mutation.schedule {
788                command.args(["--schedule", &hermes_schedule(schedule)?]);
789            }
790            if let Some(name) = &mutation.name {
791                command.args(["--name", name]);
792            }
793            if let Some(deliver) = mutation.deliver.as_ref().and_then(hermes_deliver) {
794                command.args(["--deliver", &deliver]);
795            }
796            hermes_definition_args(verb, mutation, &mut command);
797            if let Some(payload) = &mutation.payload {
798                match payload_kind(payload) {
799                    "prompt" => {
800                        command.args(["--prompt", payload_text(payload)?]);
801                    }
802                    "script" => {
803                        command.args(["--script", payload_text(payload)?]);
804                    }
805                    other => return Err(hermes_payload_refusal(other)),
806                }
807            }
808        }
809        JobVerb::Pause => {
810            command.args(["pause", &id]);
811        }
812        JobVerb::Resume => {
813            command.args(["resume", &id]);
814        }
815        JobVerb::Run => {
816            command.args(["run", &id]);
817        }
818        JobVerb::Delete => {
819            command.args(["remove", &id]);
820        }
821    }
822    Ok(command)
823}
824
825fn hermes_payload_refusal(kind: &str) -> JobControlError {
826    JobControlError::Unsupported(format!(
827        "hermes cron carries a `prompt` or a `--script` payload; it has no verb for a `{kind}` \
828         payload"
829    ))
830}
831
832fn payload_kind(payload: &JobPayloadSpec) -> &str {
833    if payload.kind.trim().is_empty() {
834        "prompt"
835    } else {
836        payload.kind.trim()
837    }
838}
839
840fn payload_text(payload: &JobPayloadSpec) -> Result<&str> {
841    payload
842        .text
843        .as_deref()
844        .filter(|text| !text.trim().is_empty())
845        .ok_or_else(|| {
846            JobControlError::Invalid(format!(
847                "a `{}` payload needs its text",
848                payload_kind(payload)
849            ))
850        })
851}
852
853// ---------------------------------------------------------------------------
854// OpenClaw — `openclaw cron …` through the Gateway
855// ---------------------------------------------------------------------------
856
857fn openclaw_command(verb: JobVerb, mutation: &JobMutation) -> Result<HarnessCommand> {
858    let connection = openclaw_connection(&mutation.homes)?;
859    let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
860    // Point the spawned CLI at the SAME state the read side addresses, using
861    // openclaw's own environment contract (`OPENCLAW_STATE_DIR` names the
862    // state dir; `OPENCLAW_CONFIG_PATH` names the config file inside it).
863    command.env(
864        "OPENCLAW_STATE_DIR",
865        mutation.homes.openclaw.to_string_lossy(),
866    );
867    command.env(
868        "OPENCLAW_CONFIG_PATH",
869        mutation
870            .homes
871            .openclaw
872            .join("openclaw.json")
873            .to_string_lossy(),
874    );
875    command.arg("cron");
876    let id = mutation.id.clone().unwrap_or_default();
877    match verb {
878        JobVerb::Create => {
879            command.arg("add");
880        }
881        JobVerb::Update => {
882            command.args(["edit", &id]);
883        }
884        // OpenClaw spells pause/resume `disable`/`enable`.
885        JobVerb::Pause => {
886            command.args(["disable", &id]);
887        }
888        JobVerb::Resume => {
889            command.args(["enable", &id]);
890        }
891        JobVerb::Run => {
892            command.args(["run", &id]);
893        }
894        JobVerb::Delete => {
895            command.args(["rm", &id]);
896        }
897    }
898    command.args(["--url", &connection.address]);
899    if let Some(token) = &connection.auth {
900        command.arg("--token");
901        command.secret(token.secret());
902    }
903    if matches!(verb, JobVerb::Create | JobVerb::Update) {
904        if let Some(name) = &mutation.name {
905            command.args(["--name", name]);
906        }
907        if let Some(schedule) = &mutation.schedule {
908            match schedule.kind.as_str() {
909                "interval" => {
910                    let minutes = schedule.minutes.ok_or_else(|| {
911                        JobControlError::Invalid("an interval schedule needs `minutes`".into())
912                    })?;
913                    command.args(["--every", &format!("{}m", trim_float(minutes))]);
914                }
915                "cron" => {
916                    let expr = schedule.expr.as_deref().ok_or_else(|| {
917                        JobControlError::Invalid("a cron schedule needs `expr`".into())
918                    })?;
919                    command.args(["--cron", expr]);
920                }
921                "once" => {
922                    let run_at = schedule.run_at.as_deref().ok_or_else(|| {
923                        JobControlError::Invalid("a once schedule needs `run_at`".into())
924                    })?;
925                    command.args(["--at", run_at]);
926                }
927                other => {
928                    return Err(JobControlError::Invalid(format!(
929                        "unknown schedule kind `{other}`; use interval, cron, or once"
930                    )))
931                }
932            }
933        }
934        if let Some(payload) = &mutation.payload {
935            match payload_kind(payload) {
936                "prompt" => {
937                    command.args(["--message", payload_text(payload)?]);
938                }
939                "system_event" => {
940                    command.args(["--system-event", payload_text(payload)?]);
941                }
942                "command" => {
943                    command.args(["--command", payload_text(payload)?]);
944                }
945                other => {
946                    return Err(JobControlError::Unsupported(format!(
947                        "openclaw cron carries `message`, `system-event` or `command` payloads; \
948                         it has no verb for a `{other}` payload"
949                    )))
950                }
951            }
952        }
953        if let Some(target) = &mutation.session_target {
954            command.args(["--session", target]);
955        }
956        if let Some(profile) = &mutation.profile {
957            command.args(["--agent", profile]);
958        }
959        if let Some(deliver) = &mutation.deliver {
960            openclaw_deliver(deliver, &mut command)?;
961        }
962        // Measured against the pin (receipt orch18-openclaw-jobs-receipt):
963        // `cron add|rm|list` accept `--json`, `cron edit` REJECTS it
964        // ("OpenClaw does not recognize option \"--json\""). The flag is only
965        // an id hint for create anyway — the answer always comes from the
966        // re-read.
967        if matches!(verb, JobVerb::Create) {
968            command.arg("--json");
969        }
970    } else if mutation.profile.is_some()
971        || mutation.session_target.is_some()
972        || mutation.deliver.is_some()
973        || mutation.name.is_some()
974        || mutation.schedule.is_some()
975        || mutation.payload.is_some()
976    {
977        return Err(JobControlError::Invalid(format!(
978            "`jobs.{}` changes no fields; pass definition fields to `jobs.update`",
979            verb.as_str()
980        )));
981    }
982    Ok(command)
983}
984
985/// OpenClaw's delivery flags. `target` is the delivery MODE the observed row
986/// reports (`announce` | `webhook` | `none`); `chat_id` is the destination.
987fn openclaw_deliver(deliver: &JobDeliverSpec, command: &mut HarnessCommand) -> Result<()> {
988    let Some(target) = deliver.target.as_deref().map(str::trim) else {
989        if let Some(chat) = deliver.chat_id.as_deref() {
990            command.args(["--to", chat]);
991        }
992        return Ok(());
993    };
994    match target {
995        "announce" => {
996            command.arg("--announce");
997            if let Some(chat) = deliver.chat_id.as_deref() {
998                command.args(["--to", chat]);
999            }
1000        }
1001        "webhook" => {
1002            let url = deliver.chat_id.as_deref().ok_or_else(|| {
1003                JobControlError::Invalid(
1004                    "an openclaw `webhook` delivery needs the URL in `chat_id`".into(),
1005                )
1006            })?;
1007            command.args(["--webhook", url]);
1008        }
1009        "none" => {
1010            command.arg("--no-deliver");
1011        }
1012        other => {
1013            return Err(JobControlError::Unsupported(format!(
1014                "openclaw delivers `announce`, `webhook`, or `none`; it has no `{other}` delivery \
1015                 mode"
1016            )))
1017        }
1018    }
1019    Ok(())
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025
1026    fn homes(root: &Path) -> HarnessHomes {
1027        HarnessHomes {
1028            hermes: root.join("hermes_home/state.db"),
1029            openclaw: root.join("openclaw_home"),
1030            ..HarnessHomes::default()
1031        }
1032    }
1033
1034    #[test]
1035    fn the_program_comes_from_the_registry_launch() {
1036        // Guard: the registry's hermes launch is the ACP BRIDGE (`hermes-acp`);
1037        // the cron verb lives on the base CLI.
1038        assert_eq!(harness_program(HarnessId::HERMES).unwrap(), "hermes");
1039        assert_eq!(harness_program(HarnessId::OPENCLAW).unwrap(), "openclaw");
1040    }
1041
1042    #[test]
1043    fn claude_code_refuses_every_mutating_verb() {
1044        let error = mutate(
1045            JobVerb::Pause,
1046            &JobMutation {
1047                harness: HarnessId::CLAUDE_CODE.into(),
1048                id: Some("release-watch".into()),
1049                ..JobMutation::default()
1050            },
1051        )
1052        .unwrap_err();
1053        assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1054        assert!(error.to_string().contains("CronCreate"), "{error}");
1055    }
1056
1057    #[test]
1058    fn a_harness_without_jobs_refuses() {
1059        let error = mutate(
1060            JobVerb::Delete,
1061            &JobMutation {
1062                harness: "codex".into(),
1063                id: Some("x".into()),
1064                ..JobMutation::default()
1065            },
1066        )
1067        .unwrap_err();
1068        assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1069    }
1070
1071    #[test]
1072    fn hermes_translates_the_uniform_row_onto_its_own_verb() {
1073        let root = PathBuf::from("/tmp/orch18-unit");
1074        let command = hermes_command(
1075            JobVerb::Create,
1076            &JobMutation {
1077                harness: HarnessId::HERMES.into(),
1078                name: Some("health".into()),
1079                schedule: Some(JobScheduleSpec {
1080                    kind: "interval".into(),
1081                    minutes: Some(10.0),
1082                    ..JobScheduleSpec::default()
1083                }),
1084                payload: Some(JobPayloadSpec {
1085                    kind: "prompt".into(),
1086                    text: Some("nightly health check".into()),
1087                }),
1088                deliver: Some(JobDeliverSpec {
1089                    target: Some("local".into()),
1090                    chat_id: None,
1091                }),
1092                homes: homes(&root),
1093                ..JobMutation::default()
1094            },
1095        )
1096        .unwrap();
1097        assert_eq!(
1098            command.narrate(),
1099            "hermes cron create --name health --deliver local 'every 10m' 'nightly health check'"
1100        );
1101        assert_eq!(
1102            command.env,
1103            vec![(
1104                "HERMES_HOME".to_string(),
1105                root.join("hermes_home").to_string_lossy().into_owned()
1106            )]
1107        );
1108    }
1109
1110    #[test]
1111    fn a_hermes_profile_is_its_own_home() {
1112        let root = PathBuf::from("/tmp/orch18-unit");
1113        let command = hermes_command(
1114            JobVerb::Pause,
1115            &JobMutation {
1116                harness: HarnessId::HERMES.into(),
1117                id: Some("abc".into()),
1118                profile: Some("ops".into()),
1119                homes: homes(&root),
1120                ..JobMutation::default()
1121            },
1122        )
1123        .unwrap();
1124        assert_eq!(command.narrate(), "hermes cron pause abc");
1125        assert_eq!(
1126            command.env[0].1,
1127            root.join("hermes_home/profiles/ops")
1128                .to_string_lossy()
1129                .into_owned()
1130        );
1131    }
1132
1133    #[test]
1134    fn hermes_refuses_a_field_it_has_no_verb_for() {
1135        let error = hermes_command(
1136            JobVerb::Update,
1137            &JobMutation {
1138                harness: HarnessId::HERMES.into(),
1139                id: Some("abc".into()),
1140                session_target: Some("isolated".into()),
1141                ..JobMutation::default()
1142            },
1143        )
1144        .unwrap_err();
1145        assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1146    }
1147
1148    /// ORC-13: the uniform row translated onto the ORCHESTRATOR's own job
1149    /// vocabulary (`docs/ORCHESTRATOR-IR.md` §2.6) — the typed schedule, the
1150    /// prompt, and Hermes's own `deliver` grammar.
1151    #[test]
1152    fn the_orchestrator_translates_the_uniform_row_onto_its_own_operator_args() {
1153        let args = orchestrator_args(
1154            JobVerb::Create,
1155            &JobMutation {
1156                harness: HarnessId::ORCHESTRATOR.into(),
1157                name: Some("health".into()),
1158                schedule: Some(JobScheduleSpec {
1159                    kind: "interval".into(),
1160                    minutes: Some(10.0),
1161                    ..JobScheduleSpec::default()
1162                }),
1163                payload: Some(JobPayloadSpec {
1164                    kind: "prompt".into(),
1165                    text: Some("nightly health check".into()),
1166                }),
1167                deliver: Some(JobDeliverSpec {
1168                    target: Some("loopback".into()),
1169                    chat_id: Some("ops-room".into()),
1170                }),
1171                ..JobMutation::default()
1172            },
1173        )
1174        .unwrap();
1175        assert_eq!(
1176            args,
1177            serde_json::json!({
1178                "name": "health",
1179                "schedule": {"kind": "interval", "minutes": 10.0},
1180                "prompt": "nightly health check",
1181                "deliver": "loopback:ops-room",
1182            })
1183        );
1184    }
1185
1186    /// A field the orchestrator's model has no home for is REFUSED, never
1187    /// dropped — the same rule the other two harnesses inherit.
1188    #[test]
1189    fn the_orchestrator_refuses_a_field_its_model_does_not_have() {
1190        for (mutation, needle) in [
1191            (
1192                JobMutation {
1193                    harness: HarnessId::ORCHESTRATOR.into(),
1194                    session_target: Some("isolated".into()),
1195                    ..JobMutation::default()
1196                },
1197                "no session-target field",
1198            ),
1199            (
1200                JobMutation {
1201                    harness: HarnessId::ORCHESTRATOR.into(),
1202                    payload: Some(JobPayloadSpec {
1203                        kind: "command".into(),
1204                        text: Some("ls".into()),
1205                    }),
1206                    ..JobMutation::default()
1207                },
1208                "there is no `command` payload",
1209            ),
1210        ] {
1211            let error = orchestrator_args(JobVerb::Create, &mutation).unwrap_err();
1212            assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1213            assert!(error.to_string().contains(needle), "{error}");
1214        }
1215        // A verb that sets no fields refuses definition fields outright.
1216        let error = orchestrator_args(
1217            JobVerb::Pause,
1218            &JobMutation {
1219                harness: HarnessId::ORCHESTRATOR.into(),
1220                id: Some("job_x".into()),
1221                name: Some("renamed".into()),
1222                ..JobMutation::default()
1223            },
1224        )
1225        .unwrap_err();
1226        assert!(matches!(error, JobControlError::Invalid(_)), "{error}");
1227    }
1228
1229    /// The root folder IS the `default` profile (§6), so an unnamed profile
1230    /// addresses it rather than defaulting to nothing.
1231    #[test]
1232    fn the_orchestrators_unnamed_profile_is_the_root_folder() {
1233        assert_eq!(
1234            orchestrator_profile(&JobMutation {
1235                harness: HarnessId::ORCHESTRATOR.into(),
1236                ..JobMutation::default()
1237            }),
1238            "default"
1239        );
1240        assert_eq!(
1241            orchestrator_profile(&JobMutation {
1242                harness: HarnessId::ORCHESTRATOR.into(),
1243                profile: Some("  coder ".into()),
1244                ..JobMutation::default()
1245            }),
1246            "coder"
1247        );
1248    }
1249
1250    #[test]
1251    fn openclaw_carries_the_gateway_endpoint_and_never_prints_the_token() {
1252        let root = std::env::temp_dir().join(format!(
1253            "supercode-orch18-unit-{}-{}",
1254            std::process::id(),
1255            std::time::SystemTime::now()
1256                .duration_since(std::time::UNIX_EPOCH)
1257                .unwrap()
1258                .as_nanos()
1259        ));
1260        let state = root.join("openclaw_home");
1261        std::fs::create_dir_all(&state).unwrap();
1262        std::fs::write(
1263            state.join("openclaw.json"),
1264            r#"{"gateway": {"port": 18999, "auth": {"token": "super-secret-token"}}}"#,
1265        )
1266        .unwrap();
1267        let command = openclaw_command(
1268            JobVerb::Create,
1269            &JobMutation {
1270                harness: HarnessId::OPENCLAW.into(),
1271                name: Some("digest".into()),
1272                schedule: Some(JobScheduleSpec {
1273                    kind: "cron".into(),
1274                    expr: Some("0 9 * * 1".into()),
1275                    ..JobScheduleSpec::default()
1276                }),
1277                payload: Some(JobPayloadSpec {
1278                    kind: "system_event".into(),
1279                    text: Some("weekly digest".into()),
1280                }),
1281                session_target: Some("main".into()),
1282                homes: homes(&root),
1283                ..JobMutation::default()
1284            },
1285        )
1286        .unwrap();
1287        assert_eq!(
1288            command.narrate(),
1289            "openclaw cron add --url ws://127.0.0.1:18999 --token <redacted> --name digest --cron \
1290             '0 9 * * 1' --system-event 'weekly digest' --session main --json"
1291        );
1292        assert_eq!(command.secrets, vec!["super-secret-token".to_string()]);
1293        assert!(
1294            !command.narrate().contains("super-secret-token"),
1295            "the credential must never be narrated"
1296        );
1297        std::fs::remove_dir_all(&root).ok();
1298    }
1299}