Skip to main content

speccoder_runtime/
progress.rs

1//! Live progress: what a run is doing while it does it.
2//!
3//! Events are a side channel — not journaled, not evidence — so replay and
4//! verdicts are untouched. [`Live`] keeps `live.json` in the project's state
5//! directory up to date (read by `speccoder report --live` from another terminal),
6//! prices token counts when `[model]` has prices, and forwards each event to
7//! the CLI's printer.
8
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13use speccoder_core::stage::{Stage, StageState};
14use speccoder_core::{CommandData, CommandResult, CommandStatus};
15
16use crate::substrate::model_config::Prices;
17
18/// The live file in a project's state directory.
19pub const FILE: &str = "live.json";
20
21/// One task of a feature run's plan.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct PlannedTask {
24    /// Task ID.
25    pub id: String,
26    /// Title.
27    pub title: String,
28    /// `done`, `ready`, `blocked` or `skipped`.
29    pub state: String,
30}
31
32/// Tool calls `live.json` keeps for watchers (`speccoder tui`).
33pub const RECENT: usize = 12;
34
35/// Something a run did.
36#[derive(Debug, Clone, PartialEq, Serialize)]
37#[serde(tag = "event", rename_all = "snake_case")]
38pub enum Event {
39    /// A feature run begins: its tasks, as the graph has them (editors show
40    /// them as a plan).
41    RunStarted {
42        /// Feature directory name.
43        feature: String,
44        /// Every task, in document order.
45        tasks: Vec<PlannedTask>,
46    },
47    /// A task attempt run begins.
48    TaskStarted {
49        /// Feature directory name.
50        feature: String,
51        /// Task ID.
52        task: String,
53        /// Task title.
54        title: String,
55    },
56    /// A stage of the task's pipeline began, ended or does not apply.
57    Stage {
58        /// Task ID.
59        task: String,
60        /// Which stage.
61        stage: Stage,
62        /// Where it is.
63        state: StageState,
64        /// `1/2 · sample 2/3` for an attempt, a short commit id, a reason.
65        detail: Option<String>,
66        /// How long it took, once it ended.
67        elapsed_ms: Option<u64>,
68        /// The last lines of a failed check (at most five).
69        tail: Vec<String>,
70    },
71    /// A run picks up an attempt a killed process left behind.
72    Resumed {
73        /// Task ID.
74        task: String,
75        /// The stage it was in, when known.
76        stage: Option<Stage>,
77        /// The last tool call, when known.
78        last_call: Option<String>,
79    },
80    /// A call waits for a person: notify them.
81    ApprovalRequested {
82        /// Task ID.
83        task: String,
84        /// The model's call id.
85        call: String,
86        /// The tool.
87        tool: String,
88        /// What waits, e.g. `cargo add x` (no control characters, ≤ 60 chars).
89        what: String,
90        /// The grant class an approval for the task or run would cover
91        /// (empty: none, the call is decided once).
92        class: String,
93    },
94    /// A person decided a call inline.
95    ApprovalDecided {
96        /// Task ID.
97        task: String,
98        /// The model's call id.
99        call: String,
100        /// `approve` or `reject`.
101        decision: String,
102        /// `once`, `task` or `run`, for an approval.
103        scope: Option<String>,
104        /// Who decided.
105        by: String,
106        /// How that is known: `asserted`, `connected` (or `expired`).
107        how: String,
108    },
109    /// A tool call the model asked for, with the policy's decision.
110    ToolCall {
111        /// Task ID.
112        task: String,
113        /// Model turn (1-based) within the attempt.
114        turn: u32,
115        /// The tool.
116        tool: String,
117        /// Its target: a path, a command, a pattern.
118        target: String,
119        /// `allow`, `deny`, `approval`, `unknown`, `stop`.
120        decision: String,
121        /// Whether an allowed call succeeded.
122        ok: Option<bool>,
123        /// The last lines of a failed command (at most five).
124        tail: Vec<String>,
125    },
126    /// Tokens so far in the current attempt.
127    Usage {
128        /// Task ID.
129        task: String,
130        /// Input tokens.
131        input_tokens: u64,
132        /// Output tokens.
133        output_tokens: u64,
134        /// Input tokens read from the cache.
135        cache_read_tokens: u64,
136        /// Input tokens written to the cache.
137        cache_write_tokens: u64,
138        /// USD, when priced.
139        usd: Option<f64>,
140    },
141    /// The task run ended.
142    TaskFinished {
143        /// Task ID.
144        task: String,
145        /// `verified`, `regression_only`, `failed`, `blocked`, …
146        outcome: String,
147        /// The run's message.
148        message: String,
149        /// The attempt's agentplane run, when one ran.
150        run: Option<String>,
151        /// Input tokens, all attempts and the acceptance call.
152        input_tokens: u64,
153        /// Output tokens.
154        output_tokens: u64,
155        /// Input tokens read from the cache.
156        cache_read_tokens: u64,
157        /// Input tokens written to the cache.
158        cache_write_tokens: u64,
159        /// USD, when priced.
160        usd: Option<f64>,
161    },
162    /// What the model said in a turn, beside its tool calls: its
163    /// account of what it is doing — a claim, never evidence. Control
164    /// characters escaped; at most 2000 characters.
165    Said {
166        /// Task ID.
167        task: String,
168        /// Model turn (1-based) within the attempt.
169        turn: u32,
170        /// The words.
171        text: String,
172    },
173    /// Anything else worth a line (convergence rounds, notices).
174    Notice {
175        /// The line.
176        text: String,
177    },
178}
179
180/// Receives events as they happen.
181pub trait Progress: Send + Sync + std::fmt::Debug {
182    /// One event.
183    fn event(&self, event: &Event);
184}
185
186/// What `live.json` holds.
187#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
188pub struct LiveState {
189    /// The speccoder process.
190    pub pid: u32,
191    /// Feature directory name.
192    pub feature: String,
193    /// Task ID.
194    pub task: String,
195    /// Task title.
196    pub title: String,
197    /// The current stage.
198    #[serde(default)]
199    pub stage: Option<Stage>,
200    /// Its detail (`1/2`, a commit id).
201    #[serde(default)]
202    pub stage_detail: Option<String>,
203    /// The last tool call, e.g. `edit_file src/lib.rs (allow)`.
204    pub last_call: Option<String>,
205    /// What the model last said, its first line (a claim).
206    #[serde(default)]
207    pub said: Option<String>,
208    /// The latest tool calls, oldest first (at most [`RECENT`]).
209    #[serde(default)]
210    pub recent: Vec<String>,
211    /// Tokens of the current attempt.
212    pub input_tokens: u64,
213    /// Output tokens of the current attempt.
214    pub output_tokens: u64,
215    /// USD of the current attempt, when priced.
216    pub usd: Option<f64>,
217    /// Set when the task run ended: its outcome and message.
218    pub finished: Option<String>,
219    /// Unix seconds when the task run started.
220    pub started_at: u64,
221    /// Unix seconds of the last event.
222    pub updated_at: u64,
223}
224
225fn now() -> u64 {
226    std::time::SystemTime::now()
227        .duration_since(std::time::UNIX_EPOCH)
228        .map_or(0, |d| d.as_secs())
229}
230
231/// USD for token counts (input includes cache reads and writes).
232#[must_use]
233pub fn usd(
234    prices: Option<Prices>,
235    input: u64,
236    output: u64,
237    cache_read: u64,
238    cache_write: u64,
239) -> Option<f64> {
240    prices.map(|p| p.usd(input, output, cache_read, cache_write))
241}
242
243/// Keeps `live.json`, prices usage, forwards to a printer.
244#[derive(Debug)]
245pub struct Live {
246    file: PathBuf,
247    prices: Option<Prices>,
248    sink: Option<Arc<dyn Progress>>,
249    state: Mutex<LiveState>,
250}
251
252impl Live {
253    /// A live record in `dir` forwarding to `sink`.
254    #[must_use]
255    pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
256        let _ = std::fs::create_dir_all(dir);
257        Self {
258            file: dir.join(FILE),
259            prices,
260            sink,
261            state: Mutex::new(LiveState {
262                pid: std::process::id(),
263                ..LiveState::default()
264            }),
265        }
266    }
267
268    fn write(&self, state: &LiveState) {
269        if let Ok(text) = serde_json::to_string_pretty(state) {
270            let tmp = self.file.with_extension("json.tmp");
271            if std::fs::write(&tmp, text).is_ok() {
272                let _ = std::fs::rename(&tmp, &self.file);
273            }
274        }
275    }
276}
277
278impl Progress for Live {
279    fn event(&self, event: &Event) {
280        let mut event = event.clone();
281        // Price what can be priced.
282        match &mut event {
283            Event::Usage {
284                input_tokens,
285                output_tokens,
286                cache_read_tokens,
287                cache_write_tokens,
288                usd: price,
289                ..
290            } => {
291                *price = usd(
292                    self.prices,
293                    *input_tokens,
294                    *output_tokens,
295                    *cache_read_tokens,
296                    *cache_write_tokens,
297                )
298            }
299            Event::TaskFinished {
300                input_tokens,
301                output_tokens,
302                cache_read_tokens,
303                cache_write_tokens,
304                usd: price,
305                ..
306            } => {
307                *price = usd(
308                    self.prices,
309                    *input_tokens,
310                    *output_tokens,
311                    *cache_read_tokens,
312                    *cache_write_tokens,
313                )
314            }
315            _ => {}
316        }
317        if let Ok(mut s) = self.state.lock() {
318            match &event {
319                Event::TaskStarted {
320                    feature,
321                    task,
322                    title,
323                } => {
324                    *s = LiveState {
325                        pid: s.pid,
326                        feature: feature.clone(),
327                        task: task.clone(),
328                        title: title.clone(),
329                        started_at: now(),
330                        ..LiveState::default()
331                    };
332                }
333                Event::Stage {
334                    stage,
335                    state: StageState::Started,
336                    detail,
337                    ..
338                } => {
339                    s.stage = Some(*stage);
340                    s.stage_detail.clone_from(detail);
341                }
342                Event::Said { text, .. } => {
343                    s.said = text
344                        .lines()
345                        .find(|l| !l.trim().is_empty())
346                        .map(|l| l.chars().take(160).collect());
347                }
348                Event::Stage { .. }
349                | Event::Resumed { .. }
350                | Event::ApprovalRequested { .. }
351                | Event::ApprovalDecided { .. } => {}
352                Event::ToolCall {
353                    turn,
354                    tool,
355                    target,
356                    decision,
357                    ..
358                } => {
359                    let call = format!("{tool} {target} ({decision})");
360                    s.recent.push(format!("turn {turn}  {call}"));
361                    if s.recent.len() > RECENT {
362                        s.recent.remove(0);
363                    }
364                    s.last_call = Some(call);
365                }
366                Event::Usage {
367                    input_tokens,
368                    output_tokens,
369                    usd,
370                    ..
371                } => {
372                    s.input_tokens = *input_tokens;
373                    s.output_tokens = *output_tokens;
374                    s.usd = *usd;
375                }
376                Event::TaskFinished {
377                    outcome, message, ..
378                } => s.finished = Some(format!("{outcome}: {message}")),
379                Event::Notice { .. } | Event::RunStarted { .. } => {}
380            }
381            s.updated_at = now();
382            self.write(&s);
383        }
384        if let Some(sink) = &self.sink {
385            sink.event(&event);
386        }
387    }
388}
389
390/// The target of a tool call, for one line.
391#[must_use]
392pub fn target(tool: &str, args: &serde_json::Value) -> String {
393    let text = match tool {
394        "run_command" => args["argv"]
395            .as_array()
396            .map(|a| {
397                a.iter()
398                    .filter_map(|v| v.as_str())
399                    .collect::<Vec<_>>()
400                    .join(" ")
401            })
402            .unwrap_or_default(),
403        "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
404        // An MCP tool: its first string argument, whatever it is called.
405        name if name.starts_with(crate::mcp::PREFIX) => args
406            .as_object()
407            .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
408            .unwrap_or_default(),
409        _ => args["path"].as_str().unwrap_or_default().to_owned(),
410    };
411    let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
412    if one.chars().count() > 60 {
413        format!("{}…", one.chars().take(59).collect::<String>())
414    } else {
415        one
416    }
417}
418
419// ── `speccoder report --live` ──────────────────────────────────────────────────────
420
421/// The project's `live.json` and whether its process is still alive.
422#[must_use]
423pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
424    let root = match crate::inspect(path, None).data() {
425        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
426        _ => return None,
427    };
428    let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
429        .ok()?;
430    let live: LiveState = serde_json::from_str(&text).ok()?;
431    let running = live.finished.is_none() && alive(live.pid);
432    Some((live, running))
433}
434
435/// Whether process `pid` is alive.
436pub fn alive(pid: u32) -> bool {
437    i32::try_from(pid)
438        .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
439}
440
441/// `speccoder report --live`: the project's current (or last) run, from
442/// `live.json`, in the state directory `state`.
443pub fn status_with(path: &Path, state: &Path) -> CommandResult {
444    // One inspection for both: what runs, and what waits.
445    let inspected = crate::inspect(path, None);
446    let root = match inspected.data() {
447        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
448        _ => return CommandResult::carried("report", &inspected),
449    };
450    let plane_dir = crate::worktree::project_state_dir(state, &root);
451    let res = status_inner(&plane_dir);
452    let lines = waiting_lines(&plane_dir);
453    if lines.is_empty() {
454        return res;
455    }
456    let mut next = res.next().to_vec();
457    next.insert(
458        0,
459        speccoder_core::next::NextStep {
460            command: "speccoder report --approvals".to_owned(),
461            why: "see the waiting calls, then approve or reject them".to_owned(),
462        },
463    );
464    let message = format!("{}\n{}", res.message(), lines.join("\n"));
465    let status = res.status();
466    res.reworded(status, &message).with_next(next)
467}
468
469/// One line per call waiting for a person: what, since when, until when.
470fn waiting_lines(plane_dir: &Path) -> Vec<String> {
471    // No journal, nothing waits — and a read never creates one.
472    if !plane_dir.join(crate::substrate::JOURNAL).exists() {
473        return Vec::new();
474    }
475    let Ok(rt) = crate::tokio_runtime() else {
476        return Vec::new();
477    };
478    let Ok(plane) = crate::substrate::Plane::open_for_decisions(plane_dir) else {
479        return Vec::new();
480    };
481    let now = agentplane::core::Timestamp::now_utc();
482    rt.block_on(plane.open_tasks())
483        .unwrap_or_default()
484        .into_iter()
485        .filter_map(|t| {
486            let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
487            let since = (now - t.created_at).whole_minutes().max(0);
488            let until = t
489                .due_at
490                .map(|d| {
491                    format!(
492                        ", denied as expired in {} h",
493                        (d - now).whole_hours().max(0)
494                    )
495                })
496                .unwrap_or_default();
497            Some(format!(
498                "waiting for a person: {} — {} (for {since} min{until})",
499                request.task, request.covers
500            ))
501        })
502        .collect()
503}
504
505fn status_inner(plane_dir: &Path) -> CommandResult {
506    let plane_dir = plane_dir.to_path_buf();
507    // An in-place session: the mode, and for an interrupted one both ways out.
508    if let Some(s) = crate::in_place::Session::load(&plane_dir) {
509        let mode = s.mode_line();
510        if !alive(s.pid) {
511            let step = |command: &str, why: &str| speccoder_core::next::NextStep {
512                command: command.to_owned(),
513                why: why.to_owned(),
514            };
515            return CommandResult::new(
516                "status",
517                CommandStatus::Ok,
518                &format!(
519                    "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
520                    s.feature, s.pid, s.branch
521                ),
522            )
523            .with_next(vec![
524                step("speccoder run --in-place", "resume where it stopped"),
525                step(
526                    "speccoder run --in-place --return",
527                    "save what it left and switch back without running",
528                ),
529            ]);
530        }
531        let res = status_live(&plane_dir);
532        let message = format!("{}\n{mode}", res.message());
533        let status = res.status();
534        return res.reworded(status, &message);
535    }
536    status_live(&plane_dir)
537}
538
539/// `status` from `live.json`.
540fn status_live(plane_dir: &Path) -> CommandResult {
541    let file = plane_dir.join(FILE);
542    let live: LiveState = match std::fs::read_to_string(&file)
543        .ok()
544        .and_then(|t| serde_json::from_str(&t).ok())
545    {
546        Some(l) => l,
547        None => {
548            return CommandResult::new(
549                "status",
550                CommandStatus::Ok,
551                "no run has been recorded for this project yet",
552            );
553        }
554    };
555    let ago = now().saturating_sub(live.updated_at);
556    let tokens = format!(
557        "{} in · {} out{}",
558        live.input_tokens,
559        live.output_tokens,
560        live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
561    );
562    use speccoder_core::next::{Outcome, next};
563    let outcome = match (&live.finished, alive(live.pid)) {
564        (None, true) => Some(Outcome::Running),
565        (None, false) => Some(Outcome::Interrupted {
566            task: live.task.clone(),
567        }),
568        (Some(_), _) => None,
569    };
570    let message = match (&live.finished, alive(live.pid)) {
571        (None, true) => format!(
572            "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}",
573            live.pid,
574            live.feature,
575            live.task,
576            stage_word(&live),
577            now().saturating_sub(live.started_at),
578            live.last_call.as_deref().unwrap_or("-"),
579            live.said
580                .as_deref()
581                .map(|s| format!("\nthe model says (a claim): “{s}”"))
582                .unwrap_or_default(),
583        ),
584        (None, false) => format!(
585            "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
586            live.feature,
587            live.task,
588            stage_word(&live),
589            live.pid
590        ),
591        (Some(outcome), _) => format!(
592            "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
593            live.feature, live.task
594        ),
595    };
596    CommandResult::new("status", CommandStatus::Ok, &message)
597        .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
598}
599
600/// `verify`, `attempt 1/2`, or `starting`.
601#[must_use]
602pub fn stage_word(live: &LiveState) -> String {
603    match (live.stage, &live.stage_detail) {
604        (Some(st), Some(d)) => format!("{} {d}", st.name()),
605        (Some(st), None) => st.name().to_owned(),
606        (None, _) => "starting".to_owned(),
607    }
608}
609
610/// The last lines of command output worth showing: at most five non-empty
611/// lines, each at most 200 characters, control characters removed.
612#[must_use]
613pub fn tail(text: &str) -> Vec<String> {
614    let lines: Vec<String> = text
615        .lines()
616        .map(|l| {
617            l.chars()
618                .filter(|c| !c.is_control())
619                .take(200)
620                .collect::<String>()
621        })
622        .filter(|l| !l.trim().is_empty())
623        .collect();
624    lines[lines.len().saturating_sub(5)..].to_vec()
625}
626
627/// [`status_with`] in the default state directory.
628#[must_use]
629pub fn status(path: &Path) -> CommandResult {
630    match crate::worktree::state_dir() {
631        Some(state) => status_with(path, &state),
632        None => CommandResult::new(
633            "status",
634            CommandStatus::ConfigError,
635            "no state directory (set SPECCODER_STATE_DIR or HOME)",
636        )
637        .with_code("NO-STATE-DIR"),
638    }
639}