Skip to main content

forge_workspace/mcp/
cron.rs

1//! Cron MCP - durable scheduled prompts (`mcp__forge__cron__*`).
2//!
3//! A forge cron fires a prompt into the session that owns it, on a
4//! schedule, and survives forge restarts (persisted to the machine-local
5//! redb store; see [`crate::store::cron`]). Unlike the cloud routines
6//! (`create_trigger` /
7//! `CronCreate`), which fire into cloud-hosted sessions, these durably
8//! target the local forge process.
9//!
10//! The tools (`cron__create` / `cron__list` / `cron__delete`) are
11//! ANY-CALLER and owner-scoped: `create` stamps the caller as the new
12//! entry's owner, and `list` / `delete` narrow to the crons the caller
13//! owns. Mirrors `agents__list`, not the lead-only `agents__spawn`.
14//! Cron-list mutations are direct `Workspace` methods (state writes), not
15//! Command-bus dispatches.
16//!
17//! - [`schedule`] - pure due-check, next-fire, and boot catch-up math.
18//! - [`facade`] - the `CronFacade` seam (prod over `Weak<Workspace>` +
19//!   a mock for tool tests).
20
21use std::sync::Arc;
22use std::time::SystemTime;
23
24use forge_sdk::mcp::server::McpServerBuilder;
25use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
26
27use forge_primitives::cron::{CronEntry, CronId, CronKind};
28
29use crate::SessionSlot;
30use crate::mcp::cron::facade::{CronCreateError, CronDeleteError, CronFacade, CronListError};
31
32pub(crate) mod facade;
33pub(crate) mod schedule;
34
35/// Attach the three cron-coordination tools to an existing
36/// [`McpServerBuilder`]. Called for BOTH lead and worker sessions (crons
37/// are any-caller), so `build_forge_server` invokes this unconditionally.
38pub(crate) fn add_tools(
39    builder: McpServerBuilder,
40    facade: Arc<dyn CronFacade>,
41    slot: SessionSlot,
42) -> McpServerBuilder {
43    let create = Create { facade: facade.clone(), slot: slot.clone() };
44    let list = List { facade: facade.clone(), slot: slot.clone() };
45    let delete = Delete { facade, slot };
46    builder.tool(create).tool(list).tool(delete)
47}
48
49fn tool_error(text: String) -> ToolOutput {
50    ToolOutput::error(text)
51}
52
53/// Parse an RFC3339 timestamp to a `SystemTime` via the `time` crate
54/// (chrono stays confined to the schedule module).
55fn parse_rfc3339(s: &str) -> Option<SystemTime> {
56    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
57        .ok()
58        .map(SystemTime::from)
59}
60
61/// Format a `SystemTime` as a UTC RFC3339 string for tool output.
62fn fmt_rfc3339(t: SystemTime) -> String {
63    time::OffsetDateTime::from(t)
64        .format(&time::format_description::well_known::Rfc3339)
65        .unwrap_or_else(|_| "unknown".to_owned())
66}
67
68/// Readable JSON for one cron entry (the tool-output shape the LLM sees).
69fn cron_to_json(entry: &CronEntry) -> serde_json::Value {
70    let schedule = match &entry.kind {
71        CronKind::Recurring(expr) => serde_json::json!({ "recurring": expr }),
72        CronKind::Once(at) => serde_json::json!({ "once_at": fmt_rfc3339(*at) }),
73    };
74    let mut obj = serde_json::json!({
75        "id": entry.id.as_str(),
76        "project": entry.project_name,
77        "schedule": schedule,
78        "prompt": entry.prompt,
79        "next_fire": fmt_rfc3339(entry.next_fire),
80    });
81    if let (Some(desc), Some(map)) = (&entry.description, obj.as_object_mut()) {
82        map.insert("description".to_owned(), serde_json::Value::String(desc.clone()));
83    }
84    if !entry.wake
85        && let Some(map) = obj.as_object_mut()
86    {
87        map.insert("wake".to_owned(), serde_json::Value::Bool(false));
88    }
89    obj
90}
91
92fn format_create_error(err: &CronCreateError) -> String {
93    match err {
94        CronCreateError::UnknownCallerProject => {
95            "couldn't resolve your project; is this session attached to a forge.toml project?"
96                .to_owned()
97        }
98        CronCreateError::InvalidExpression(msg) => format!("invalid cron expression: {msg}"),
99        CronCreateError::NoUpcomingOccurrence => {
100            "that schedule has no upcoming occurrence (a run-once time in the past, or a cron \
101             expression that never matches)"
102                .to_owned()
103        }
104    }
105}
106
107struct Create {
108    facade: Arc<dyn CronFacade>,
109    slot: SessionSlot,
110}
111
112#[derive(serde::Deserialize)]
113struct CreateArgs {
114    #[serde(default)]
115    schedule: Option<String>,
116    #[serde(default)]
117    run_once_at: Option<String>,
118    prompt: String,
119    #[serde(default)]
120    description: Option<String>,
121    #[serde(default)]
122    wake: Option<bool>,
123}
124
125#[async_trait::async_trait]
126impl Tool for Create {
127    fn name(&self) -> &'static str {
128        "cron__create"
129    }
130
131    fn description(&self) -> &'static str {
132        "Schedule a durable prompt that fires into the session that registers it and survives \
133         forge restarts. Provide EXACTLY ONE of `schedule` (a 5-field cron expression like \
134         \"0 9 * * *\" for 9am daily, evaluated in your local timezone) or `run_once_at` (an \
135         RFC3339 timestamp like \"2026-07-01T09:00:00Z\" for a single fire), plus `prompt` (the \
136         text delivered as a user turn when it fires). Optionally pass `description` - a short \
137         human summary of what the job does and why - which the UI shows as the schedule's \
138         headline. If that session isn't open at fire time, forge spawns it first, unless it can \
139         no longer be started (a worker whose directory is gone); pass `wake: false` for a \
140         job that must not wake a sleeping owner, where a tick that finds the \
141         session asleep is skipped and the entry stays due for the next session start. \
142         Returns the cron's id (use it \
143         with cron__delete). Recurring crons repeat; run-once crons delete themselves after \
144         firing. A run-once cron is a poor fit for retrying a transient failure - a rate limit \
145         or a blip clears in seconds, so the retry fires later as a user turn into a session \
146         that has moved on. The cron is registered to you, and cron__list and cron__delete act \
147         on the crons you registered. Any session in the project may create crons."
148    }
149
150    fn input_schema(&self) -> serde_json::Value {
151        serde_json::json!({
152            "type": "object",
153            "properties": {
154                "schedule": {
155                    "type": "string",
156                    "description": "A 5-field cron expression (minute hour day month weekday), \
157                                    evaluated in the host's local timezone. Mutually exclusive \
158                                    with run_once_at.",
159                },
160                "run_once_at": {
161                    "type": "string",
162                    "description": "An RFC3339 timestamp for a single fire. Mutually exclusive \
163                                    with schedule.",
164                },
165                "prompt": {
166                    "type": "string",
167                    "description": "The prompt delivered as a user turn when the cron fires.",
168                },
169                "description": {
170                    "type": "string",
171                    "description": "Optional short human summary of what this job does and why \
172                                    (e.g. \"Morning summary\"). Shown as the schedule's \
173                                    headline in the UI; falls back to the prompt's first line \
174                                    when omitted.",
175                },
176                "wake": {
177                    "type": "boolean",
178                    "description": "Whether a fire may wake a sleeping session (default true). \
179                                    Set false for a job that must not wake a sleeping \
180                                    owner: a tick that finds the session asleep is skipped and \
181                                    the entry stays due, so the next session start (or a tick \
182                                    while it is awake) runs it.",
183                },
184            },
185            "required": ["prompt"],
186            "additionalProperties": false,
187        })
188    }
189
190    async fn call(&self, input: ToolInput) -> ToolOutput {
191        let args: CreateArgs = match serde_json::from_value(input.value) {
192            Ok(a) => a,
193            Err(err) => return tool_error(format!("invalid arguments: {err}")),
194        };
195        let kind = match (args.schedule, args.run_once_at) {
196            (Some(expr), None) => CronKind::Recurring(expr),
197            (None, Some(rfc)) => match parse_rfc3339(&rfc) {
198                Some(t) => CronKind::Once(t),
199                None => {
200                    return tool_error(format!(
201                        "run_once_at must be an RFC3339 timestamp (e.g. 2026-07-01T09:00:00Z); \
202                         got {rfc:?}"
203                    ));
204                }
205            },
206            (Some(_), Some(_)) => {
207                return tool_error(
208                    "provide exactly one of `schedule` or `run_once_at`, not both".to_owned(),
209                );
210            }
211            (None, None) => {
212                return tool_error(
213                    "provide either `schedule` (a 5-field cron expression) or `run_once_at` (an \
214                     RFC3339 timestamp)"
215                        .to_owned(),
216                );
217            }
218        };
219        let description = args.description.and_then(|d| {
220            let trimmed = d.trim();
221            (!trimmed.is_empty()).then(|| trimmed.to_owned())
222        });
223        match self.facade.create_cron(
224            &self.slot,
225            kind,
226            args.prompt,
227            description,
228            args.wake.unwrap_or(true),
229        ) {
230            Ok(entry) => match serde_json::to_string_pretty(&cron_to_json(&entry)) {
231                Ok(json) => ToolOutput::text(json),
232                Err(err) => tool_error(format!("response serialization failed: {err}")),
233            },
234            Err(err) => tool_error(format_create_error(&err)),
235        }
236    }
237}
238
239struct List {
240    facade: Arc<dyn CronFacade>,
241    slot: SessionSlot,
242}
243
244#[async_trait::async_trait]
245impl Tool for List {
246    fn name(&self) -> &'static str {
247        "cron__list"
248    }
249
250    fn description(&self) -> &'static str {
251        "List the durable crons you registered, in your project. Returns a JSON array of {id, \
252         project, schedule, prompt, next_fire}. Use an id with cron__delete. An empty array means \
253         you have no crons registered; an unresolvable caller, or a run that could not read the \
254         durable cron store, is an error rather than an empty list. Takes no \
255         arguments. Any session in the project may call this."
256    }
257
258    fn input_schema(&self) -> serde_json::Value {
259        serde_json::json!({
260            "type": "object",
261            "properties": {},
262            "additionalProperties": false,
263        })
264    }
265
266    async fn call(&self, _input: ToolInput) -> ToolOutput {
267        let crons = match self.facade.list_crons(&self.slot) {
268            Ok(crons) => crons,
269            Err(CronListError::UnknownCallerProject) => {
270                return tool_error("couldn't resolve your project".to_owned());
271            }
272            Err(CronListError::Unavailable) => {
273                return tool_error(
274                    "couldn't load durable crons this run (the forge log names why), so this is \
275                     not an empty cron list; crons may exist that this run cannot see. Ask the \
276                     user to restart forge to retry the load."
277                        .to_owned(),
278                );
279            }
280        };
281        let arr: Vec<serde_json::Value> = crons.iter().map(cron_to_json).collect();
282        match serde_json::to_string_pretty(&serde_json::Value::Array(arr)) {
283            Ok(json) => ToolOutput::text(json),
284            Err(err) => tool_error(format!("cron-list serialization failed: {err}")),
285        }
286    }
287}
288
289struct Delete {
290    facade: Arc<dyn CronFacade>,
291    slot: SessionSlot,
292}
293
294#[derive(serde::Deserialize)]
295struct DeleteArgs {
296    id: String,
297}
298
299#[async_trait::async_trait]
300impl Tool for Delete {
301    fn name(&self) -> &'static str {
302        "cron__delete"
303    }
304
305    fn description(&self) -> &'static str {
306        "Delete a durable cron you registered, in your project, by id (from cron__list / \
307         cron__create). Returns the removed entry. Any session in the project may call this."
308    }
309
310    fn input_schema(&self) -> serde_json::Value {
311        serde_json::json!({
312            "type": "object",
313            "properties": {
314                "id": { "type": "string", "description": "The cron id to delete." },
315            },
316            "required": ["id"],
317            "additionalProperties": false,
318        })
319    }
320
321    async fn call(&self, input: ToolInput) -> ToolOutput {
322        let args: DeleteArgs = match serde_json::from_value(input.value) {
323            Ok(a) => a,
324            Err(err) => return tool_error(format!("invalid arguments: {err}")),
325        };
326        match self.facade.delete_cron(&self.slot, &CronId::from(args.id.as_str())) {
327            Ok(entry) => {
328                let envelope = crate::mcp::deleted::removed_record(&cron_to_json(&entry));
329                match serde_json::to_string_pretty(&envelope) {
330                    Ok(json) => ToolOutput::text(json),
331                    Err(err) => tool_error(format!("response serialization failed: {err}")),
332                }
333            }
334            Err(CronDeleteError::UnknownCallerProject) => {
335                tool_error("couldn't resolve your project".to_owned())
336            }
337            Err(CronDeleteError::NoSuchCron) => {
338                tool_error(format!("no cron with id {} in this project", args.id))
339            }
340            Err(CronDeleteError::NotOwnedByCaller) => tool_error(format!(
341                "cron {} exists in this project but was not registered by you",
342                args.id
343            )),
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::mcp::cron::facade::MockCronFacade;
352    use crate::mcp::test_support::text_of;
353
354    fn caller_slot() -> SessionSlot {
355        SessionSlot::from_str_for_test("caller")
356    }
357
358    fn sample_entry(id: &str) -> CronEntry {
359        CronEntry {
360            id: CronId::from(id),
361            project_name: "forge".to_owned(),
362            kind: CronKind::Recurring("0 9 * * *".to_owned()),
363            prompt: "p".to_owned(),
364            created_at: SystemTime::UNIX_EPOCH,
365            description: None,
366            last_fire: None,
367            next_fire: SystemTime::UNIX_EPOCH,
368            team_role: None,
369            wake: true,
370        }
371    }
372
373    fn input(value: serde_json::Value) -> ToolInput {
374        ToolInput { value }
375    }
376
377    #[tokio::test]
378    async fn create_with_cron_expr_calls_facade_and_returns_entry() {
379        let mock = Arc::new(MockCronFacade::new());
380        *mock.create_result.lock() = Some(Ok(sample_entry("c1")));
381        let tool = Create { facade: mock.clone(), slot: caller_slot() };
382
383        let out = tool
384            .call(input(serde_json::json!({ "schedule": "0 9 * * *", "prompt": "stand-up" })))
385            .await;
386        assert!(!out.is_error, "valid create succeeds: {}", text_of(&out));
387        assert!(text_of(&out).contains("c1"), "output carries the id");
388
389        let calls = mock.create_calls.lock();
390        assert_eq!(calls.len(), 1);
391        assert!(matches!(&calls[0].1, CronKind::Recurring(e) if e == "0 9 * * *"));
392        assert_eq!(calls[0].2, "stand-up");
393        assert_eq!(calls[0].3, None, "no description arg reaches the facade as None");
394        assert!(calls[0].4, "an unstated `wake` reaches the facade as the waking default");
395    }
396
397    /// The output states `wake: false` whether or not the entry carries a
398    /// description: a caller reading a no-wake cron back must see it, and the
399    /// two fields are independent.
400    #[test]
401    fn a_no_wake_entry_states_it_without_a_description() {
402        let mut entry = sample_entry("c1");
403        assert_eq!(entry.description, None, "premise: no description");
404        entry.wake = false;
405        let json = cron_to_json(&entry);
406        assert_eq!(json["wake"], serde_json::Value::Bool(false), "the no-wake flag is stated");
407    }
408
409    /// A job that must not wake a sleeping owner passes `wake: false`, and
410    /// the tool threads it: the scheduler then skips a sleeping owner
411    /// instead of spawning it.
412    #[tokio::test]
413    async fn create_threads_wake_false_for_a_no_wake_job() {
414        let mock = Arc::new(MockCronFacade::new());
415        let tool = Create { facade: mock.clone(), slot: caller_slot() };
416
417        let out = tool
418            .call(input(serde_json::json!({
419                "schedule": "0 6 * * 1",
420                "prompt": "refresh the index",
421                "wake": false
422            })))
423            .await;
424        assert!(!out.is_error, "a no-wake create succeeds: {}", text_of(&out));
425        assert!(!mock.create_calls.lock()[0].4, "the no-wake flag reaches the facade");
426    }
427
428    #[tokio::test]
429    async fn create_threads_and_trims_description() {
430        let mock = Arc::new(MockCronFacade::new());
431        let tool = Create { facade: mock.clone(), slot: caller_slot() };
432
433        let out = tool
434            .call(input(serde_json::json!({
435                "schedule": "0 9 * * *",
436                "prompt": "stand-up",
437                "description": "  Morning summary  "
438            })))
439            .await;
440        assert!(!out.is_error, "create with description succeeds: {}", text_of(&out));
441        assert_eq!(
442            mock.create_calls.lock()[0].3.as_deref(),
443            Some("Morning summary"),
444            "the description is trimmed and threaded to the facade",
445        );
446
447        let blank = tool
448            .call(input(serde_json::json!({
449                "schedule": "0 9 * * *",
450                "prompt": "stand-up",
451                "description": "   "
452            })))
453            .await;
454        assert!(!blank.is_error);
455        assert_eq!(
456            mock.create_calls.lock()[1].3,
457            None,
458            "a whitespace-only description collapses to None",
459        );
460    }
461
462    #[tokio::test]
463    async fn create_with_run_once_at_parses_rfc3339() {
464        let mock = Arc::new(MockCronFacade::new());
465        let tool = Create { facade: mock.clone(), slot: caller_slot() };
466
467        let out = tool
468            .call(input(
469                serde_json::json!({ "run_once_at": "2030-01-01T09:00:00Z", "prompt": "deploy" }),
470            ))
471            .await;
472        assert!(!out.is_error, "valid rfc3339 accepted: {}", text_of(&out));
473        assert!(matches!(mock.create_calls.lock()[0].1, CronKind::Once(_)));
474    }
475
476    #[tokio::test]
477    async fn create_rejects_bad_rfc3339_without_touching_facade() {
478        let mock = Arc::new(MockCronFacade::new());
479        let tool = Create { facade: mock.clone(), slot: caller_slot() };
480
481        let out = tool
482            .call(input(serde_json::json!({ "run_once_at": "not a date", "prompt": "x" })))
483            .await;
484        assert!(out.is_error);
485        assert!(mock.create_calls.lock().is_empty(), "invalid input never reaches the facade");
486    }
487
488    #[tokio::test]
489    async fn create_rejects_both_and_neither_schedule() {
490        let mock = Arc::new(MockCronFacade::new());
491        let tool = Create { facade: mock.clone(), slot: caller_slot() };
492
493        let both = tool
494            .call(input(serde_json::json!({
495                "schedule": "0 9 * * *",
496                "run_once_at": "2030-01-01T09:00:00Z",
497                "prompt": "x"
498            })))
499            .await;
500        assert!(both.is_error, "both schedule kinds is an error");
501
502        let neither = tool.call(input(serde_json::json!({ "prompt": "x" }))).await;
503        assert!(neither.is_error, "no schedule kind is an error");
504    }
505
506    #[tokio::test]
507    async fn create_surfaces_invalid_expression_error() {
508        let mock = Arc::new(MockCronFacade::new());
509        *mock.create_result.lock() =
510            Some(Err(CronCreateError::InvalidExpression("bad pattern".to_owned())));
511        let tool = Create { facade: mock.clone(), slot: caller_slot() };
512
513        let out =
514            tool.call(input(serde_json::json!({ "schedule": "nonsense", "prompt": "x" }))).await;
515        assert!(out.is_error);
516        assert!(text_of(&out).contains("bad pattern"), "facade error surfaced to the LLM");
517    }
518
519    #[tokio::test]
520    async fn list_returns_the_callers_crons() {
521        let mock = Arc::new(MockCronFacade::new());
522        *mock.crons.lock() = vec![sample_entry("a"), sample_entry("b")];
523        let tool = List { facade: mock.clone(), slot: caller_slot() };
524
525        let out = tool.call(input(serde_json::json!({}))).await;
526        assert!(!out.is_error);
527        assert!(text_of(&out).contains("\"a\"") && text_of(&out).contains("\"b\""));
528    }
529
530    /// `[]` names exactly one state - the caller has no crons registered. A
531    /// caller that cannot be mapped to a project is a different answer, and
532    /// the delete beside it already gives that answer, so a list that folded
533    /// the two together left a live cron invisible.
534    #[tokio::test]
535    async fn list_refuses_an_unresolvable_caller_instead_of_answering_empty() {
536        let mock = Arc::new(MockCronFacade::new());
537        *mock.list_result.lock() = Some(Err(CronListError::UnknownCallerProject));
538        let tool = List { facade: mock.clone(), slot: caller_slot() };
539
540        let out = tool.call(input(serde_json::json!({}))).await;
541        assert!(
542            out.is_error,
543            "an unresolvable caller must not read as an empty registry: {}",
544            text_of(&out),
545        );
546        assert_eq!(text_of(&out), "couldn't resolve your project");
547    }
548
549    /// A boot that could not load the durable crons has an empty in-memory
550    /// set while rows may still be on disk. The tool must say so rather than
551    /// answering `[]`, which a caller reads as "you have no crons" and acts
552    /// on by deleting nothing.
553    #[tokio::test]
554    async fn list_reports_an_unavailable_store_as_an_error() {
555        let mock = Arc::new(MockCronFacade::new());
556        *mock.list_result.lock() = Some(Err(CronListError::Unavailable));
557        let tool = List { facade: mock.clone(), slot: caller_slot() };
558
559        let out = tool.call(input(serde_json::json!({}))).await;
560        assert!(
561            out.is_error,
562            "an unavailable cron store must not read as an empty registry: {}",
563            text_of(&out),
564        );
565        assert!(
566            text_of(&out).contains("couldn't load durable crons"),
567            "the error names the case: {}",
568            text_of(&out),
569        );
570        assert!(
571            text_of(&out).contains("not an empty cron list"),
572            "the error says the empty set is not the registry: {}",
573            text_of(&out),
574        );
575    }
576
577    #[tokio::test]
578    async fn delete_removes_by_id() {
579        let mock = Arc::new(MockCronFacade::new());
580        *mock.delete_result.lock() = Some(Ok(sample_entry("c1")));
581        let tool = Delete { facade: mock.clone(), slot: caller_slot() };
582
583        let out = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
584        assert!(!out.is_error);
585        assert_eq!(mock.delete_calls.lock()[0].1, CronId::from("c1"));
586    }
587
588    /// A delete echoes the entry it removed in the adopted envelope, whole:
589    /// the record is compared against `cron_to_json`, so the schedule, the
590    /// prompt and the description cannot be dropped or renamed while a
591    /// field-wise reading still passes.
592    #[tokio::test]
593    async fn delete_echoes_the_removed_entry() {
594        let mock = Arc::new(MockCronFacade::new());
595        let mut entry = sample_entry("c1");
596        entry.description = Some("Morning summary".to_owned());
597        *mock.delete_result.lock() = Some(Ok(entry.clone()));
598        let tool = Delete { facade: mock.clone(), slot: caller_slot() };
599
600        let out = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
601        assert!(!out.is_error, "delete succeeds: {out:?}");
602        assert_eq!(out.blocks.len(), 1, "the envelope stays one text block: {out:?}");
603        let json: serde_json::Value =
604            serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
605        assert_eq!(json["status"], "deleted");
606        assert_eq!(
607            json["removed"],
608            cron_to_json(&entry),
609            "the echo is the whole entry, not a hand-picked subset: {json}",
610        );
611    }
612
613    /// A refusal has to say which case it is: an id that exists in the
614    /// project but is not the caller's own reads as *not yours*, an id
615    /// the project never had reads as *not there*. One string for both
616    /// lets a caller take its own refused delete as proof the cron is
617    /// gone.
618    #[tokio::test]
619    async fn delete_refusals_name_the_case() {
620        let mock = Arc::new(MockCronFacade::new());
621        let tool = Delete { facade: mock.clone(), slot: caller_slot() };
622
623        *mock.delete_result.lock() = Some(Err(CronDeleteError::NotOwnedByCaller));
624        let not_yours = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
625        assert!(not_yours.is_error, "a refused delete is an error");
626        assert_eq!(
627            text_of(&not_yours),
628            "cron c1 exists in this project but was not registered by you",
629            "a cron that exists but is not the caller's own must say so, without naming a session",
630        );
631
632        *mock.delete_result.lock() = Some(Err(CronDeleteError::NoSuchCron));
633        let not_there = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
634        assert!(not_there.is_error, "an unknown id is an error");
635        assert_eq!(
636            text_of(&not_there),
637            "no cron with id c1 in this project",
638            "an id the project never had must not read as another session's cron",
639        );
640    }
641
642    /// Both tools narrow to the crons the caller owns, so a description
643    /// claiming project scope sends a caller looking for crons it could
644    /// never see - and one naming another session's crons sends it
645    /// looking for a set it is not meant to query at all.
646    #[test]
647    fn tool_descriptions_scope_to_the_caller_not_the_project() {
648        // Wordings that disclose crons beyond the caller's own. A net, not
649        // a closure: whether a wording discloses is a judgement over
650        // open-ended prose, so no list of these is complete, and this is
651        // the only guard against a sentence appended beside the scope
652        // clause - the scope pin covers a reword of that clause, not an
653        // addition next to it. Bigrams rather than the bare word `session`,
654        // so "Any session in the project may call this" survives.
655        const DISCLOSING: &[&str] = &[
656            "another session",
657            "other session",
658            "other worker",
659            "other owner",
660            "each session",
661            "sibling",
662            "separate sets",
663            "another agent",
664        ];
665
666        let mock = MockCronFacade::new().into_arc();
667        let slot = caller_slot();
668        let create = Create { facade: mock.clone(), slot: slot.clone() };
669        let list = List { facade: mock.clone(), slot: slot.clone() };
670        let delete = Delete { facade: mock, slot };
671        let list_desc = list.description();
672
673        for (name, desc) in [
674            ("cron__create", create.description()),
675            ("cron__list", list_desc),
676            ("cron__delete", delete.description()),
677        ] {
678            assert!(
679                desc.contains("you registered"),
680                "{name} must name the caller's own crons rather than the project: {desc}",
681            );
682            let lowered = desc.to_lowercase();
683            for shape in DISCLOSING {
684                assert!(
685                    !lowered.contains(shape),
686                    "{name} must not disclose that other sessions' crons exist ({shape}): {desc}",
687                );
688            }
689        }
690        assert!(
691            list_desc.contains("An empty array means you have no crons registered"),
692            "an empty list must read as the caller having none, never as the project having \
693             none: {list_desc}",
694        );
695        // `[]` names exactly one state: the caller has no crons. Every other
696        // condition - an unresolvable caller, a failed durable read - is an
697        // error on the same verb now, so a description that warns about them
698        // again re-opens the ambiguity the error channel closed.
699        for stale in [
700            "could not be resolved",
701            "could not read its stored crons",
702            "could not load its durable crons",
703        ] {
704            assert!(
705                !list_desc.contains(stale),
706                "an empty list means one thing; the description must not carry the old \
707                 '{stale}' caveat: {list_desc}",
708            );
709        }
710        assert!(
711            list_desc.contains("could not read the durable cron store"),
712            "the description must name the failed durable read: {list_desc}",
713        );
714    }
715
716    /// A worker's directory can be gone with its row kept, and then
717    /// nothing starts the owner and the fire does not land - so the
718    /// spawn promise cannot be unconditional.
719    #[test]
720    fn cron_create_description_qualifies_the_spawn_promise() {
721        let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
722        let desc = create.description();
723        assert!(
724            desc.contains("unless it can no longer be started"),
725            "the spawn promise must carry the case where the owner cannot be started: {desc}",
726        );
727    }
728
729    /// The `wake: false` example is what a fresh lead meets when it reaches
730    /// for the tool, so it names the flag's own meaning rather than the
731    /// standing sweep the charter forbids. Both places that describe the
732    /// parameter are pinned together.
733    #[test]
734    fn cron_create_description_keeps_the_no_wake_example_generic() {
735        let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
736        let desc = create.description();
737        assert!(
738            desc.contains("a job that must not wake a sleeping owner"),
739            "the no-wake example names the flag's meaning: {desc}",
740        );
741        assert!(
742            !desc.contains("standing sweep"),
743            "the no-wake example must not sell a standing sweep: {desc}",
744        );
745        let schema = create.input_schema();
746        let wake = schema["properties"]["wake"]["description"]
747            .as_str()
748            .expect("the wake parameter carries a description");
749        assert!(
750            wake.contains("a job that must not wake a sleeping owner"),
751            "the wake parameter names the flag's meaning: {wake}",
752        );
753        assert!(
754            !wake.contains("standing sweep"),
755            "the wake parameter must not sell a standing sweep: {wake}",
756        );
757    }
758
759    /// Run-once scheduling is advertised as a feature, so the description
760    /// is where a session decides to schedule a retry - and a transient
761    /// failure clears in seconds, long before the cron fires. The rule
762    /// and the staleness that makes it a rule are pinned together.
763    #[test]
764    fn cron_create_description_discourages_retry_crons() {
765        let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
766        let desc = create.description();
767        assert!(
768            desc.contains("A run-once cron is a poor fit for retrying a transient failure"),
769            "the retry-cron rule is pinned: {desc}",
770        );
771        assert!(
772            desc.contains("the retry fires later as a user turn into a session that has moved on"),
773            "the reason the retry is stale is pinned: {desc}",
774        );
775    }
776
777    /// Any-caller is an affordance a caller-scoped description cannot
778    /// carry, and a worker cannot infer it: it is who may CALL, not what
779    /// another session's crons hold.
780    #[test]
781    fn tool_descriptions_keep_the_any_caller_sentence() {
782        let mock = MockCronFacade::new().into_arc();
783        let slot = caller_slot();
784        let create = Create { facade: mock.clone(), slot: slot.clone() };
785        let list = List { facade: mock.clone(), slot: slot.clone() };
786        let delete = Delete { facade: mock, slot };
787        for (name, desc) in [
788            ("cron__create", create.description()),
789            ("cron__list", list.description()),
790            ("cron__delete", delete.description()),
791        ] {
792            assert!(
793                desc.contains("Any session in the project may"),
794                "{name} must keep saying who may call it: {desc}",
795            );
796        }
797    }
798
799    /// The fired prompt lands in the session that registered the cron,
800    /// which for a worker is its own - so a description naming the
801    /// project's session points that worker at a session it is not
802    /// talking to.
803    #[test]
804    fn cron_create_description_names_the_registering_session_as_the_fire_target() {
805        let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
806        let desc = create.description();
807        assert!(
808            desc.contains("fires into the session that registers it"),
809            "the create description must say where the prompt lands: {desc}",
810        );
811        assert!(
812            !desc.contains("project's session"),
813            "the prompt does not land in the project's session: {desc}",
814        );
815    }
816
817    #[test]
818    fn tool_names_are_the_cron_family() {
819        // Assert the base tool names only. Combined with the `forge`
820        // server name (proven in mcp::tests::build_forge_server_*) they
821        // render as `mcp__forge__cron__<x>` on the LLM side, which the SDK
822        // auto-approve fast-path covers via the `mcp__forge__` prefix
823        // (asserted in forge-sdk options.rs).
824        let mock = MockCronFacade::new().into_arc();
825        let slot = caller_slot();
826        let create = Create { facade: mock.clone(), slot: slot.clone() };
827        let list = List { facade: mock.clone(), slot: slot.clone() };
828        let delete = Delete { facade: mock, slot };
829        assert_eq!(create.name(), "cron__create");
830        assert_eq!(list.name(), "cron__list");
831        assert_eq!(delete.name(), "cron__delete");
832    }
833}