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