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