Skip to main content

orch/engine/
jobs.rs

1//! Job scheduler — replaces `jobs_tick.sh`.
2//!
3//! Reads job definitions from `jobs.yml`, checks cron schedules against
4//! the current time, and creates tasks for due jobs. Handles catch-up
5//! for missed schedules (capped at 24h).
6//!
7//! Job types:
8//! - `task`: creates a task (GitHub Issue or internal SQLite) and lets the engine dispatch it
9//! - `bash`: runs a shell command directly (no LLM)
10//! - `self-review`: analyzes task metrics and creates self-improvement issues
11//!
12//! For task jobs, the `external` field controls where the task is created:
13//! - `external: false` (default): Creates an internal SQLite task
14//! - `external: true`: Creates a GitHub Issue
15
16use crate::backends::{ExternalBackend, ExternalId};
17use crate::channels::notification::TaskNotification;
18use crate::channels::transport::Transport;
19use crate::cmd::CommandErrorContext;
20use crate::store::{JobState, TaskStatus};
21use anyhow::Context;
22use cron::Schedule;
23use serde::{Deserialize, Serialize};
24use std::path::{Path, PathBuf};
25use std::str::FromStr;
26use std::sync::Arc;
27
28// Note: self-review analysis is now performed by scheduled tasks configured
29// in `.orch.yml` and executed as regular `task` jobs. Hardcoded self-review
30// logic was removed to keep this module a generic job dispatcher.
31
32/// A scheduled job definition (from jobs.yml).
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Job {
35    pub id: String,
36    #[serde(default = "default_job_type")]
37    pub r#type: String,
38    pub schedule: String,
39    #[serde(default)]
40    pub task: Option<TaskTemplate>,
41    #[serde(default)]
42    pub command: Option<String>,
43    #[serde(default)]
44    pub dir: Option<String>,
45    #[serde(default = "default_enabled")]
46    pub enabled: bool,
47    #[serde(default = "default_external")]
48    pub external: bool, // true = GitHub Issue, false (default) = internal SQLite task
49    /// Send a Telegram notification when this job completes or fails.
50    /// Uses `channels.telegram.chat_id` from config unless `notify_target` overrides it.
51    #[serde(default)]
52    pub notify: bool,
53    /// Override the Telegram chat_id for this job's completion notification.
54    /// Only used when `notify: true`. Falls back to `channels.telegram.chat_id`.
55    #[serde(default)]
56    pub notify_target: Option<String>,
57    /// Path to the `prompts/jobs/*.md` file this job was loaded from, if any.
58    ///
59    /// `None` means the job was defined inline in `.orch.yml` / `config.yml`.
60    /// Set by `scan_prompt_jobs`; skipped during serialization so it never
61    /// leaks into config files.
62    #[serde(skip)]
63    pub prompt_file: Option<PathBuf>,
64}
65
66/// Template for creating a task from a job.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TaskTemplate {
69    pub title: String,
70    #[serde(default)]
71    pub body: String,
72    /// Path to a file (typically markdown) whose contents are used as the
73    /// task body. Resolved relative to the directory of the config file
74    /// that defines this job. Mutually exclusive with `body`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub prompt: Option<String>,
77    #[serde(default)]
78    pub labels: Vec<String>,
79    #[serde(default)]
80    pub agent: Option<String>,
81}
82
83/// Resolve the body for a task template, reading from `prompt` if set.
84///
85/// `base_dir` is the directory containing the jobs config file (used to
86/// resolve relative `prompt` paths).
87pub fn resolve_template_body(template: &TaskTemplate, base_dir: &Path) -> anyhow::Result<String> {
88    match template.prompt.as_deref() {
89        Some(rel) => {
90            let path = Path::new(rel);
91            let full = if path.is_absolute() {
92                path.to_path_buf()
93            } else {
94                base_dir.join(path)
95            };
96            std::fs::read_to_string(&full)
97                .with_context(|| format!("reading prompt file {}", full.display()))
98        }
99        None => Ok(template.body.clone()),
100    }
101}
102
103fn default_job_type() -> String {
104    "task".to_string()
105}
106
107fn default_enabled() -> bool {
108    true
109}
110
111fn default_external() -> bool {
112    false // Default to internal (SQLite) tasks
113}
114
115/// Top-level config structure (for reading jobs from .orch.yml / config.yml).
116#[derive(Debug, Serialize, Deserialize)]
117struct ConfigFile {
118    #[serde(default)]
119    jobs: Vec<Job>,
120    // Capture all other fields so we can round-trip them
121    #[serde(flatten)]
122    other: serde_norway::Mapping,
123}
124
125/// Resolve the config file that contains jobs.
126///
127/// Priority:
128/// 1. `.orch.yml` in the current directory (project config)
129/// 2. `.orch.yml` in any registered project directory (from global config)
130/// 3. `~/.orch/config.yml` (global config)
131pub fn resolve_jobs_path() -> PathBuf {
132    // 1. Check cwd
133    let project = PathBuf::from(".orch.yml");
134    if project.exists() {
135        return project;
136    }
137
138    // 2. Check registered project directories (handles brew service running from /)
139    if let Ok(paths) = crate::config::get_project_paths() {
140        for path_str in &paths {
141            let candidate = PathBuf::from(path_str).join(".orch.yml");
142            if candidate.exists() {
143                return candidate;
144            }
145        }
146    }
147
148    // 3. Fall back to global config
149    crate::home::config_path().unwrap_or_else(|_| PathBuf::from(".orch/config.yml"))
150}
151
152/// Load jobs from the orch config file plus any markdown prompt files
153/// discovered under `<base_dir>/prompts/jobs/*.md`.
154///
155/// Inline `jobs:` in `.orch.yml` and discovered prompt files are merged;
156/// duplicate ids across the two sources are rejected.
157pub fn load_jobs(path: &PathBuf) -> anyhow::Result<Vec<Job>> {
158    let base_dir = path
159        .parent()
160        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
161    let mut jobs: Vec<Job> = if path.exists() {
162        let content =
163            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
164        let file: ConfigFile = serde_norway::from_str(&content)
165            .with_context(|| format!("parsing {}", path.display()))?;
166        file.jobs
167    } else {
168        vec![]
169    };
170
171    let scanned = scan_prompt_jobs(&base_dir)?;
172    let mut seen: std::collections::HashSet<String> = jobs.iter().map(|j| j.id.clone()).collect();
173    for job in scanned {
174        if !seen.insert(job.id.clone()) {
175            anyhow::bail!(
176                "duplicate job id '{}' — defined in both '{}' and prompts/jobs/",
177                job.id,
178                path.display()
179            );
180        }
181        jobs.push(job);
182    }
183
184    for job in &jobs {
185        validate_job(job, &base_dir)?;
186    }
187    Ok(jobs)
188}
189
190fn validate_job(job: &Job, base_dir: &Path) -> anyhow::Result<()> {
191    let expanded = crate::cron::expand_alias(&job.schedule)
192        .with_context(|| format!("job '{}': invalid cron schedule '{}'", job.id, job.schedule))?;
193    let normalized = crate::cron::normalize_dow(&expanded);
194    let full_expr = format!("0 {normalized} *");
195    Schedule::from_str(&full_expr)
196        .with_context(|| format!("job '{}': invalid cron schedule '{}'", job.id, job.schedule))?;
197    if let Some(template) = job.task.as_ref() {
198        if template.prompt.is_some() && !template.body.is_empty() {
199            anyhow::bail!(
200                "job '{}': set either 'task.body' or 'task.prompt', not both",
201                job.id
202            );
203        }
204        if let Some(rel) = template.prompt.as_deref() {
205            let candidate = Path::new(rel);
206            let full = if candidate.is_absolute() {
207                candidate.to_path_buf()
208            } else {
209                base_dir.join(candidate)
210            };
211            if !full.exists() {
212                anyhow::bail!(
213                    "job '{}': prompt file not found: {}",
214                    job.id,
215                    full.display()
216                );
217            }
218        }
219    }
220    Ok(())
221}
222
223/// Frontmatter schema for `prompts/jobs/*.md` files.
224#[derive(Debug, Deserialize)]
225struct JobFrontmatter {
226    id: String,
227    schedule: String,
228    title: String,
229    #[serde(default = "default_job_type")]
230    r#type: String,
231    #[serde(default = "default_enabled")]
232    enabled: bool,
233    #[serde(default = "default_external")]
234    external: bool,
235    #[serde(default)]
236    labels: Vec<String>,
237    #[serde(default)]
238    agent: Option<String>,
239    #[serde(default)]
240    notify: bool,
241    #[serde(default)]
242    notify_target: Option<String>,
243    #[serde(default)]
244    command: Option<String>,
245    #[serde(default)]
246    dir: Option<String>,
247}
248
249/// Split a markdown file into (frontmatter, body). Returns `None` if the file
250/// does not start with a `---` fence.
251fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
252    let rest = content
253        .strip_prefix("---\n")
254        .or_else(|| content.strip_prefix("---\r\n"))?;
255    let end = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n"))?;
256    let fm = &rest[..end];
257    let after = &rest[end..];
258    let body = after
259        .strip_prefix("\n---\n")
260        .or_else(|| after.strip_prefix("\n---\r\n"))
261        .unwrap_or(after);
262    Some((fm, body.trim_start_matches('\n')))
263}
264
265/// Scan `<base_dir>/prompts/jobs/*.md` for job-defining markdown files.
266pub fn scan_prompt_jobs(base_dir: &Path) -> anyhow::Result<Vec<Job>> {
267    let dir = base_dir.join("prompts").join("jobs");
268    if !dir.is_dir() {
269        return Ok(vec![]);
270    }
271    let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
272        .with_context(|| format!("reading {}", dir.display()))?
273        .filter_map(std::result::Result::ok)
274        .map(|e| e.path())
275        .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
276        .collect();
277    entries.sort();
278
279    let mut jobs = Vec::with_capacity(entries.len());
280    for path in entries {
281        let content = std::fs::read_to_string(&path)
282            .with_context(|| format!("reading {}", path.display()))?;
283        let Some((fm, body)) = split_frontmatter(&content) else {
284            continue; // No frontmatter → treat as a plain prompt referenced from .orch.yml
285        };
286        let meta: JobFrontmatter = serde_norway::from_str(fm)
287            .with_context(|| format!("parsing frontmatter in {}", path.display()))?;
288        jobs.push(Job {
289            id: meta.id,
290            r#type: meta.r#type,
291            schedule: meta.schedule,
292            task: Some(TaskTemplate {
293                title: meta.title,
294                body: body.to_string(),
295                prompt: None,
296                labels: meta.labels,
297                agent: meta.agent,
298            }),
299            command: meta.command,
300            dir: meta.dir,
301            enabled: meta.enabled,
302            external: meta.external,
303            notify: meta.notify,
304            notify_target: meta.notify_target,
305            prompt_file: Some(path.clone()),
306        });
307    }
308    Ok(jobs)
309}
310
311/// Save jobs back to the config file, preserving all other keys.
312pub fn save_jobs(path: &PathBuf, jobs: &[Job]) -> anyhow::Result<()> {
313    // Read the existing file to preserve non-jobs keys
314    let mut file: ConfigFile = if path.exists() {
315        let content =
316            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
317        serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?
318    } else {
319        ConfigFile {
320            jobs: vec![],
321            other: serde_norway::Mapping::new(),
322        }
323    };
324
325    // Only persist inline jobs (those without a prompt_file source).
326    // Prompt-based jobs live in prompts/jobs/*.md and must not be duplicated
327    // into .orch.yml — doing so causes a "duplicate job id" error on the next
328    // load_jobs call.
329    file.jobs = jobs
330        .iter()
331        .filter(|j| j.prompt_file.is_none())
332        .cloned()
333        .collect();
334    let content = serde_norway::to_string(&file)?;
335    std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
336    Ok(())
337}
338
339/// Update the `enabled:` field in the YAML frontmatter of a prompt-based job
340/// file, preserving all other content unchanged.
341///
342/// The function rewrites only the `enabled:` line inside the opening `---`
343/// fence.  If the field is absent it is inserted before the closing `---`.
344pub fn set_frontmatter_enabled(path: &Path, enabled: bool) -> anyhow::Result<()> {
345    let original =
346        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
347
348    let value = if enabled { "true" } else { "false" };
349
350    // Replace an existing `enabled: <anything>` line inside the frontmatter.
351    // We only touch the first YAML block (between the opening and first closing
352    // `---` fences) to avoid accidentally rewriting the body.
353    let rewritten = rewrite_frontmatter_enabled(&original, value);
354    std::fs::write(path, rewritten).with_context(|| format!("writing {}", path.display()))?;
355    Ok(())
356}
357
358/// Rewrite the `enabled:` key inside the first `---` fence block of `content`.
359///
360/// If the key is present, its value is replaced.  If absent, it is appended
361/// just before the closing `---` line.  The rest of the document is unchanged.
362fn rewrite_frontmatter_enabled(content: &str, value: &str) -> String {
363    // Locate the opening fence.
364    let Some(rest) = content
365        .strip_prefix("---\n")
366        .or_else(|| content.strip_prefix("---\r\n"))
367    else {
368        // No frontmatter: return unchanged.
369        return content.to_string();
370    };
371
372    // Find the closing fence.
373    let Some(close_pos) = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n")) else {
374        return content.to_string();
375    };
376
377    let fm = &rest[..close_pos];
378    let after_close = &rest[close_pos..]; // starts with "\n---\n" or "\n---\r\n"
379
380    // Rebuild the frontmatter, replacing or inserting `enabled:`.
381    let new_line = format!("enabled: {value}");
382    let mut found = false;
383    let new_fm: String = fm
384        .lines()
385        .map(|line| {
386            if line == "enabled: true" || line == "enabled: false" || line.starts_with("enabled: ")
387            {
388                found = true;
389                new_line.clone()
390            } else {
391                line.to_string()
392            }
393        })
394        .collect::<Vec<_>>()
395        .join("\n");
396
397    let new_fm = if found {
398        new_fm
399    } else {
400        format!("{new_fm}\n{new_line}")
401    };
402
403    format!("---\n{new_fm}{after_close}")
404}
405
406/// Returns `true` if the error indicates a "not found" (404) response.
407///
408/// Used to distinguish permanent task deletion from transient API errors.
409/// A 404 should clear `active_task_id`; other errors (rate limit, 5xx,
410/// network timeout) should be retried next tick without losing the reference.
411fn is_not_found_error(e: &anyhow::Error) -> bool {
412    // Walk the full error chain. Both the HTTP backend ("GitHub API GET {url}
413    // failed (404): ...") and the CLI backend ("... 404 ...") include "404"
414    // in the error string, matching the convention used throughout this codebase.
415    e.chain().any(|cause| cause.to_string().contains("404"))
416}
417
418/// Check all jobs and execute due ones.
419///
420/// Runtime state (last_run, active_task_id, last_task_status) is stored in
421/// SQLite via the `job_state` table, not in the YAML config file.
422pub async fn tick(
423    jobs_path: &Path,
424    backend: &Arc<dyn ExternalBackend>,
425    store: Option<&Arc<crate::store::TaskStore>>,
426    repo: &str,
427    transport: Option<&Arc<Transport>>,
428) -> anyhow::Result<()> {
429    let jobs_path_clone = jobs_path.to_path_buf();
430    let jobs = match tokio::task::spawn_blocking(move || load_jobs(&jobs_path_clone))
431        .await
432        .map_err(|e| anyhow::anyhow!("load_jobs panicked: {e}"))
433        .and_then(|r| r)
434    {
435        Ok(j) => j,
436        Err(e) => {
437            tracing::error!(
438                path = %jobs_path.display(),
439                repo = %repo,
440                ?e,
441                "job config invalid — skipping scheduler tick for this project"
442            );
443            return Ok(());
444        }
445    };
446    let now = chrono::Utc::now();
447
448    for job in &jobs {
449        if !job.enabled {
450            continue;
451        }
452
453        // Load runtime state from SQLite
454        let mut state = if let Some(s) = store {
455            match s.get_job_state(repo, &job.id).await {
456                Ok(Some(st)) => st,
457                Ok(None) => JobState {
458                    repo: repo.to_string(),
459                    job_id: job.id.clone(),
460                    last_run: None,
461                    last_task_status: None,
462                    active_task_id: None,
463                },
464                Err(e) => {
465                    tracing::error!(job_id = job.id, ?e, "failed to load job state, skipping");
466                    continue;
467                }
468            }
469        } else {
470            JobState {
471                repo: repo.to_string(),
472                job_id: job.id.clone(),
473                last_run: None,
474                last_task_status: None,
475                active_task_id: None,
476            }
477        };
478
479        // Check if schedule matches
480        let is_due = match &state.last_run {
481            Some(last) => match crate::cron::check(&job.schedule, Some(last)) {
482                Ok(v) => v,
483                Err(e) => {
484                    tracing::error!(
485                        job_id = job.id,
486                        schedule = job.schedule,
487                        ?e,
488                        "invalid cron expression, skipping"
489                    );
490                    continue;
491                }
492            },
493            None => match crate::cron::check(&job.schedule, None) {
494                Ok(v) => v,
495                Err(e) => {
496                    tracing::error!(
497                        job_id = job.id,
498                        schedule = job.schedule,
499                        ?e,
500                        "invalid cron expression, skipping"
501                    );
502                    continue;
503                }
504            },
505        };
506
507        if !is_due {
508            continue;
509        }
510
511        // Check if previous task is still active
512        let mut should_clear_task_id = false;
513        let mut should_skip = false;
514
515        if let Some(ref task_id) = state.active_task_id
516            && task_id.is_empty()
517        {
518            state.active_task_id = None;
519        }
520
521        if let Some(ref task_id) = state.active_task_id {
522            let task_id_clone = task_id.clone();
523            let is_active = if job.external {
524                // Check external (GitHub) task
525                match backend.get_task(&ExternalId(task_id_clone.clone())).await {
526                    Ok(task) => {
527                        let status = task.labels.iter().find(|l| l.starts_with("status:"));
528                        match status.map(std::string::String::as_str) {
529                            Some(
530                                "status:in_progress"
531                                | "status:routed"
532                                | "status:new"
533                                | "status:needs_review"
534                                | "status:in_review"
535                                | "status:approved"
536                                | "status:waiting",
537                            )
538                            | None => true, // no status label is treated as active
539                            Some(_) => false, // Terminal state
540                        }
541                    }
542                    Err(e) => {
543                        if is_not_found_error(&e) {
544                            tracing::warn!(
545                                job_id = job.id,
546                                task_id = task_id_clone,
547                                "active task not found (404), clearing active_task_id"
548                            );
549                            should_clear_task_id = true;
550                            state.last_task_status = Some("error".to_string());
551                        } else {
552                            tracing::warn!(
553                                job_id = job.id,
554                                task_id = task_id_clone,
555                                ?e,
556                                "transient error fetching active task, skipping tick (active_task_id preserved)"
557                            );
558                            should_skip = true;
559                        }
560                        false
561                    }
562                }
563            } else {
564                // Check internal task via store
565                if let Some(s) = store {
566                    if let Ok(Some(store_id)) = s.resolve_task_id(repo, &task_id_clone).await {
567                        match s.get(store_id).await {
568                            Ok(task) => match task.status {
569                                TaskStatus::New
570                                | TaskStatus::Routed
571                                | TaskStatus::InProgress
572                                | TaskStatus::NeedsReview
573                                | TaskStatus::InReview
574                                | TaskStatus::Approved
575                                | TaskStatus::Waiting => true,
576                                _ => false, // Terminal state (Done, Blocked, Cancelled)
577                            },
578                            Err(e) => {
579                                tracing::warn!(
580                                    job_id = job.id,
581                                    task_id = task_id_clone,
582                                    ?e,
583                                    "cannot fetch internal task from store, clearing active_task_id"
584                                );
585                                should_clear_task_id = true;
586                                false
587                            }
588                        }
589                    } else {
590                        tracing::warn!(
591                            job_id = job.id,
592                            task_id = task_id_clone,
593                            "internal task not found in store, clearing active_task_id"
594                        );
595                        should_clear_task_id = true;
596                        false
597                    }
598                } else {
599                    should_clear_task_id = true;
600                    false
601                }
602            };
603
604            if is_active {
605                tracing::debug!(
606                    job_id = job.id,
607                    task_id = task_id_clone,
608                    external = job.external,
609                    "skipping: previous task still active"
610                );
611                should_skip = true;
612            }
613        }
614
615        // Apply deferred mutations
616        if should_clear_task_id {
617            state.active_task_id = None;
618        }
619
620        if should_skip {
621            continue;
622        }
623
624        tracing::info!(job_id = job.id, r#type = job.r#type, "job due, executing");
625
626        // Persist last_run BEFORE execution to prevent catch-up on restart.
627        // On restart the runtime state is loaded from SQLite, so writing
628        // last_run here ensures a crash during execution won't cause the
629        // job to be considered due again immediately.
630        state.last_run = Some(now.format("%Y-%m-%dT%H:%M:%SZ").to_string());
631        if let Some(s) = store
632            && let Err(e) = s.upsert_job_state(&state).await
633        {
634            tracing::error!(
635                job_id = job.id,
636                ?e,
637                "failed to persist job state before execution — skipping this tick to preserve restart safety"
638            );
639            continue; // retry next tick instead of risking duplicate execution
640        }
641
642        // Execute the job (may mutate state.active_task_id / last_task_status).
643        let base_dir = jobs_path.parent().unwrap_or_else(|| Path::new("."));
644        execute_job(job, &mut state, backend, store, repo, transport, base_dir).await;
645
646        // Persist updated state (active_task_id, last_task_status) after execution.
647        if let Some(s) = store
648            && let Err(e) = s.upsert_job_state(&state).await
649        {
650            tracing::error!(
651                job_id = job.id,
652                ?e,
653                "failed to persist job state after execution"
654            );
655        }
656    }
657
658    Ok(())
659}
660
661/// Execute a single job immediately (ignoring schedule and active-task guard).
662///
663/// Updates `state` with the result but does NOT persist to SQLite — caller is
664/// responsible for that.
665pub async fn execute_job(
666    job: &Job,
667    state: &mut JobState,
668    backend: &Arc<dyn ExternalBackend>,
669    store: Option<&Arc<crate::store::TaskStore>>,
670    repo: &str,
671    transport: Option<&Arc<Transport>>,
672    base_dir: &Path,
673) {
674    match job.r#type.as_str() {
675        "task" => {
676            if let Some(ref template) = job.task {
677                let body = match resolve_template_body(template, base_dir) {
678                    Ok(b) => b,
679                    Err(e) => {
680                        tracing::error!(job_id = job.id, ?e, "failed to resolve task body");
681                        state.last_task_status = Some("failed".to_string());
682                        return;
683                    }
684                };
685
686                if job.external {
687                    let mut labels = template.labels.clone();
688                    labels.push("scheduled".to_string());
689                    labels.push(format!("job:{}", job.id));
690
691                    if let Some(ref agent) = template.agent
692                        && !agent.is_empty()
693                    {
694                        labels.push(format!("agent:{agent}"));
695                    }
696
697                    match backend.create_task(&template.title, &body, &labels).await {
698                        Ok(ext_id) => {
699                            tracing::info!(
700                                job_id = job.id,
701                                task_id = ext_id.0,
702                                "created external task"
703                            );
704                            state.active_task_id = Some(ext_id.0);
705                            state.last_task_status = Some("new".to_string());
706                        }
707                        Err(e) => {
708                            tracing::error!(job_id = job.id, ?e, "failed to create external task");
709                            state.last_task_status = Some("failed".to_string());
710                        }
711                    }
712                } else if let Some(s) = store {
713                    match s
714                        .create_internal(repo, &template.title, &body, "cron", &job.id, None)
715                        .await
716                    {
717                        Ok(internal_id) => {
718                            let task_id = format!("internal:{internal_id}");
719                            tracing::info!(job_id = job.id, task_id, "created internal task");
720                            state.active_task_id = Some(task_id);
721                            state.last_task_status = Some("new".to_string());
722                        }
723                        Err(e) => {
724                            tracing::error!(job_id = job.id, ?e, "failed to create internal task");
725                            state.last_task_status = Some("failed".to_string());
726                        }
727                    }
728                } else {
729                    tracing::error!(
730                        job_id = job.id,
731                        "no store available for internal task creation"
732                    );
733                    state.last_task_status = Some("failed".to_string());
734                }
735            }
736        }
737        "bash" => {
738            if let Some(ref cmd) = job.command {
739                let dir = job.dir.as_deref().unwrap_or(".");
740                tracing::info!(job_id = job.id, cmd, dir, "running bash command");
741
742                // Configurable timeout with 5-minute default. A bash job that hangs
743                // (network operation, blocking subprocess) would otherwise park a Tokio
744                // worker for its entire duration, blocking the inline tick loop.
745                let timeout_secs: u64 = crate::config::get("jobs.bash_timeout_seconds")
746                    .ok()
747                    .and_then(|s| s.parse().ok())
748                    .unwrap_or(300);
749                let timeout = std::time::Duration::from_secs(timeout_secs);
750
751                let output = tokio::time::timeout(
752                    timeout,
753                    tokio::process::Command::new("bash")
754                        .arg("-c")
755                        .arg(cmd)
756                        .current_dir(dir)
757                        .output_with_context(),
758                )
759                .await;
760
761                match output {
762                    Ok(Ok(o)) if o.status.success() => {
763                        state.last_task_status = Some("done".to_string());
764                    }
765                    Ok(Ok(o)) => {
766                        let stderr = String::from_utf8_lossy(&o.stderr);
767                        tracing::warn!(
768                            job_id = job.id,
769                            code = o.status.code(),
770                            %stderr,
771                            "bash command failed"
772                        );
773                        state.last_task_status = Some("failed".to_string());
774                    }
775                    Ok(Err(e)) => {
776                        tracing::error!(job_id = job.id, ?e, "bash command error");
777                        state.last_task_status = Some("failed".to_string());
778                    }
779                    Err(_) => {
780                        tracing::error!(
781                            job_id = job.id,
782                            secs = timeout_secs,
783                            "bash job timed out after {}s — killing to unblock tick loop",
784                            timeout_secs
785                        );
786                        state.last_task_status = Some("timeout".to_string());
787                    }
788                }
789            }
790        }
791        // Note: previous versions supported a special-case "self-review"
792        // job type with embedded analysis logic. That logic has been removed
793        // — self-review work should be configured as a regular `task` job in
794        // `.orch.yml` and executed by agents. Unknown job types fall through
795        // to the warning handler below.
796        other => {
797            tracing::warn!(job_id = job.id, r#type = other, "unknown job type");
798        }
799    }
800
801    // Send Telegram notification if requested for this job.
802    if job.notify
803        && let Some(t) = transport
804    {
805        let status = state
806            .last_task_status
807            .as_deref()
808            .unwrap_or("unknown")
809            .to_string();
810        let summary = match status.as_str() {
811            "done" => format!("Job `{}` completed successfully.", job.id),
812            "failed" => format!("Job `{}` failed.", job.id),
813            "new" => format!("Job `{}` created a new task.", job.id),
814            other => format!("Job `{}` finished with status: {other}.", job.id),
815        };
816        // Resolve target chat_id: job override → config default
817        let target = job
818            .notify_target
819            .clone()
820            .or_else(|| crate::config::get("channels.telegram.chat_id").ok());
821        let notification = TaskNotification {
822            task_id: job.id.clone(),
823            title: format!("Job: {}", job.id),
824            status,
825            agent: "scheduler".to_string(),
826            duration_seconds: 0.0,
827            summary,
828            repo: Some(repo.to_string()),
829            notify_target: target,
830            pr_number: None,
831        };
832        t.push_notification(notification);
833    }
834}
835
836// NOTE: self-review analysis is handled by scheduled `task` jobs configured
837// in `.orch.yml` and executed by agents. There is no embedded Rust logic
838// here — this module remains a generic job dispatcher.
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use std::fs;
844    use tempfile::tempdir;
845
846    fn write_config(dir: &Path, content: &str) -> PathBuf {
847        let path = dir.join(".orch.yml");
848        fs::write(&path, content).unwrap();
849        path
850    }
851
852    #[test]
853    fn load_jobs_reads_prompt_from_file() {
854        let tmp = tempdir().unwrap();
855        fs::create_dir(tmp.path().join("prompts")).unwrap();
856        fs::write(tmp.path().join("prompts/hello.md"), "hello from file").unwrap();
857        let cfg = write_config(
858            tmp.path(),
859            r#"
860jobs:
861  - id: with-prompt
862    schedule: "0 9 * * *"
863    task:
864      title: "Daily"
865      prompt: prompts/hello.md
866"#,
867        );
868
869        let jobs = load_jobs(&cfg).unwrap();
870        assert_eq!(jobs.len(), 1);
871        let body = resolve_template_body(jobs[0].task.as_ref().unwrap(), tmp.path()).unwrap();
872        assert_eq!(body, "hello from file");
873    }
874
875    #[test]
876    fn load_jobs_rejects_both_body_and_prompt() {
877        let tmp = tempdir().unwrap();
878        fs::write(tmp.path().join("p.md"), "x").unwrap();
879        let cfg = write_config(
880            tmp.path(),
881            r#"
882jobs:
883  - id: conflict
884    schedule: "0 9 * * *"
885    task:
886      title: "x"
887      body: "inline"
888      prompt: p.md
889"#,
890        );
891
892        let err = load_jobs(&cfg).unwrap_err().to_string();
893        assert!(err.contains("not both"), "unexpected error: {err}");
894    }
895
896    #[test]
897    fn load_jobs_rejects_missing_prompt_file() {
898        let tmp = tempdir().unwrap();
899        let cfg = write_config(
900            tmp.path(),
901            r#"
902jobs:
903  - id: missing
904    schedule: "0 9 * * *"
905    task:
906      title: "x"
907      prompt: does-not-exist.md
908"#,
909        );
910
911        let err = load_jobs(&cfg).unwrap_err().to_string();
912        assert!(
913            err.contains("prompt file not found"),
914            "unexpected error: {err}"
915        );
916    }
917
918    #[test]
919    fn resolve_template_body_falls_back_to_inline_body() {
920        let tmp = tempdir().unwrap();
921        let template = TaskTemplate {
922            title: "t".to_string(),
923            body: "inline body".to_string(),
924            prompt: None,
925            labels: vec![],
926            agent: None,
927        };
928        assert_eq!(
929            resolve_template_body(&template, tmp.path()).unwrap(),
930            "inline body"
931        );
932    }
933
934    fn write_prompt_job(dir: &Path, name: &str, content: &str) {
935        let jobs_dir = dir.join("prompts").join("jobs");
936        fs::create_dir_all(&jobs_dir).unwrap();
937        fs::write(jobs_dir.join(name), content).unwrap();
938    }
939
940    #[test]
941    fn scan_prompt_jobs_picks_up_frontmatter_files() {
942        let tmp = tempdir().unwrap();
943        write_prompt_job(
944            tmp.path(),
945            "morning.md",
946            "---\nid: morning\nschedule: '0 9 * * *'\ntitle: Morning\nlabels: [daily]\nagent: claude\n---\n\nDo morning stuff.\n",
947        );
948        let jobs = scan_prompt_jobs(tmp.path()).unwrap();
949        assert_eq!(jobs.len(), 1);
950        assert_eq!(jobs[0].id, "morning");
951        assert_eq!(jobs[0].schedule, "0 9 * * *");
952        let task = jobs[0].task.as_ref().unwrap();
953        assert_eq!(task.title, "Morning");
954        assert_eq!(task.body, "Do morning stuff.\n");
955        assert_eq!(task.labels, vec!["daily".to_string()]);
956        assert_eq!(task.agent.as_deref(), Some("claude"));
957    }
958
959    #[test]
960    fn load_jobs_merges_inline_and_scanned() {
961        let tmp = tempdir().unwrap();
962        write_prompt_job(
963            tmp.path(),
964            "scanned.md",
965            "---\nid: scanned\nschedule: '0 9 * * *'\ntitle: Scanned\n---\n\nbody\n",
966        );
967        let cfg = write_config(
968            tmp.path(),
969            r#"
970jobs:
971  - id: inline
972    schedule: "0 10 * * *"
973    task:
974      title: "Inline"
975      body: "x"
976"#,
977        );
978        let jobs = load_jobs(&cfg).unwrap();
979        let ids: Vec<&str> = jobs.iter().map(|j| j.id.as_str()).collect();
980        assert!(ids.contains(&"inline"));
981        assert!(ids.contains(&"scanned"));
982        assert_eq!(jobs.len(), 2);
983    }
984
985    #[test]
986    fn load_jobs_rejects_duplicate_id_across_sources() {
987        let tmp = tempdir().unwrap();
988        write_prompt_job(
989            tmp.path(),
990            "dup.md",
991            "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
992        );
993        let cfg = write_config(
994            tmp.path(),
995            r#"
996jobs:
997  - id: dup
998    schedule: "0 10 * * *"
999    task:
1000      title: "Dup inline"
1001      body: "x"
1002"#,
1003        );
1004        let err = load_jobs(&cfg).unwrap_err().to_string();
1005        assert!(err.contains("duplicate job id"), "unexpected error: {err}");
1006    }
1007
1008    #[test]
1009    fn scan_prompt_jobs_ignores_files_without_frontmatter() {
1010        let tmp = tempdir().unwrap();
1011        write_prompt_job(
1012            tmp.path(),
1013            "plain.md",
1014            "just a prompt body, no frontmatter\n",
1015        );
1016        let jobs = scan_prompt_jobs(tmp.path()).unwrap();
1017        assert_eq!(jobs.len(), 0);
1018    }
1019
1020    #[test]
1021    fn load_jobs_works_without_config_file_when_directory_present() {
1022        let tmp = tempdir().unwrap();
1023        write_prompt_job(
1024            tmp.path(),
1025            "solo.md",
1026            "---\nid: solo\nschedule: '0 9 * * *'\ntitle: Solo\n---\n\nbody\n",
1027        );
1028        let missing_cfg = tmp.path().join(".orch.yml");
1029        let jobs = load_jobs(&missing_cfg).unwrap();
1030        assert_eq!(jobs.len(), 1);
1031        assert_eq!(jobs[0].id, "solo");
1032    }
1033
1034    // ──── Issue #3193: frontmatter `enabled: false` must be respected ────────
1035
1036    #[test]
1037    fn scan_prompt_jobs_respects_enabled_false() {
1038        let tmp = tempdir().unwrap();
1039        write_prompt_job(
1040            tmp.path(),
1041            "disabled.md",
1042            "---\nid: disabled\nschedule: '0 9 * * *'\ntitle: Disabled\nenabled: false\n---\n\nbody\n",
1043        );
1044        let jobs = scan_prompt_jobs(tmp.path()).unwrap();
1045        assert_eq!(jobs.len(), 1);
1046        assert!(!jobs[0].enabled, "job should be disabled via frontmatter");
1047    }
1048
1049    #[test]
1050    fn scan_prompt_jobs_sets_prompt_file() {
1051        let tmp = tempdir().unwrap();
1052        write_prompt_job(
1053            tmp.path(),
1054            "morning.md",
1055            "---\nid: morning\nschedule: '0 9 * * *'\ntitle: Morning\n---\n\nbody\n",
1056        );
1057        let jobs = scan_prompt_jobs(tmp.path()).unwrap();
1058        assert_eq!(jobs.len(), 1);
1059        assert!(
1060            jobs[0].prompt_file.is_some(),
1061            "prompt_file should be set for frontmatter jobs"
1062        );
1063    }
1064
1065    #[test]
1066    fn save_jobs_does_not_persist_prompt_based_jobs() {
1067        let tmp = tempdir().unwrap();
1068        // A prompt-based job
1069        write_prompt_job(
1070            tmp.path(),
1071            "prompt.md",
1072            "---\nid: from-prompt\nschedule: '0 9 * * *'\ntitle: Prompt Job\n---\n\nbody\n",
1073        );
1074        // An inline job in .orch.yml
1075        let cfg = write_config(
1076            tmp.path(),
1077            "jobs:\n  - id: inline\n    schedule: \"0 10 * * *\"\n    task:\n      title: Inline\n      body: x\n",
1078        );
1079        let jobs = load_jobs(&cfg).unwrap();
1080        assert_eq!(jobs.len(), 2);
1081
1082        // save_jobs should only write the inline job, not the prompt-based one
1083        save_jobs(&cfg, &jobs).unwrap();
1084
1085        let saved = load_jobs(&cfg).unwrap();
1086        assert_eq!(saved.len(), 2, "both jobs should still be loadable");
1087        let ids: Vec<&str> = saved.iter().map(|j| j.id.as_str()).collect();
1088        assert!(ids.contains(&"inline"));
1089        assert!(ids.contains(&"from-prompt"));
1090    }
1091
1092    #[test]
1093    fn rewrite_frontmatter_enabled_replaces_existing_value() {
1094        let input = "---\nid: foo\nschedule: daily\nenabled: true\ntitle: Foo\n---\n\nbody\n";
1095        let out = rewrite_frontmatter_enabled(input, "false");
1096        assert!(
1097            out.contains("enabled: false"),
1098            "should replace enabled: true"
1099        );
1100        assert!(!out.contains("enabled: true"), "old value must be gone");
1101        assert!(out.contains("body"), "body must be preserved");
1102    }
1103
1104    #[test]
1105    fn rewrite_frontmatter_enabled_inserts_when_absent() {
1106        let input = "---\nid: foo\nschedule: daily\ntitle: Foo\n---\n\nbody\n";
1107        let out = rewrite_frontmatter_enabled(input, "false");
1108        assert!(
1109            out.contains("enabled: false"),
1110            "should insert enabled: false"
1111        );
1112        assert!(out.contains("body"), "body must be preserved");
1113    }
1114
1115    #[test]
1116    fn set_frontmatter_enabled_writes_file() {
1117        let tmp = tempdir().unwrap();
1118        let path = tmp.path().join("job.md");
1119        fs::write(
1120            &path,
1121            "---\nid: foo\nschedule: daily\ntitle: Foo\nenabled: true\n---\n\nbody\n",
1122        )
1123        .unwrap();
1124        set_frontmatter_enabled(&path, false).unwrap();
1125        let content = fs::read_to_string(&path).unwrap();
1126        assert!(content.contains("enabled: false"));
1127        assert!(!content.contains("enabled: true"));
1128    }
1129}