1use 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
18pub const FILE: &str = "live.json";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct PlannedTask {
24 pub id: String,
26 pub title: String,
28 pub state: String,
30}
31
32pub const RECENT: usize = 12;
34
35#[derive(Debug, Clone, PartialEq, Serialize)]
37#[serde(tag = "event", rename_all = "snake_case")]
38pub enum Event {
39 RunStarted {
42 feature: String,
44 tasks: Vec<PlannedTask>,
46 },
47 TaskStarted {
49 feature: String,
51 task: String,
53 title: String,
55 },
56 Stage {
58 task: String,
60 stage: Stage,
62 state: StageState,
64 detail: Option<String>,
66 elapsed_ms: Option<u64>,
68 tail: Vec<String>,
70 },
71 Resumed {
73 task: String,
75 stage: Option<Stage>,
77 last_call: Option<String>,
79 },
80 ApprovalRequested {
82 task: String,
84 call: String,
86 tool: String,
88 what: String,
90 class: String,
93 object: String,
96 },
97 ApprovalDecided {
99 task: String,
101 call: String,
103 decision: String,
105 scope: Option<String>,
107 by: String,
109 how: String,
111 class: String,
113 object: String,
115 },
116 ToolCall {
118 task: String,
120 turn: u32,
122 tool: String,
124 target: String,
126 decision: String,
128 ok: Option<bool>,
130 tail: Vec<String>,
132 },
133 Usage {
135 task: String,
137 input_tokens: u64,
139 output_tokens: u64,
141 cache_read_tokens: u64,
143 cache_write_tokens: u64,
145 usd: Option<f64>,
147 },
148 TaskFinished {
150 task: String,
152 outcome: String,
154 message: String,
156 run: Option<String>,
158 input_tokens: u64,
160 output_tokens: u64,
162 cache_read_tokens: u64,
164 cache_write_tokens: u64,
166 usd: Option<f64>,
168 regression: Option<speccoder_core::regression::Regression>,
170 },
171 CollectionChecked {
174 task: String,
176 files: Vec<CollectionFile>,
178 },
179 RegressionBisected {
181 task: String,
183 runs: u32,
185 result: Option<String>,
187 },
188 Said {
192 task: String,
194 turn: u32,
196 text: String,
198 },
199 Reasoning {
203 task: String,
205 turn: u32,
207 text: String,
209 },
210 Notice {
212 text: String,
214 },
215 Bug {
217 task: String,
219 step: BugEvent,
221 },
222 Oracle {
225 step: OracleEvent,
227 },
228 PlanningStopped {
230 unit: Option<String>,
232 kind: String,
234 next_skill: Option<String>,
236 },
237 Project {
241 step: ProjectEvent,
243 },
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248#[serde(tag = "event", rename_all = "snake_case")]
249pub enum ProjectEvent {
250 ScaffoldStarted {
252 step: String,
254 argv: Vec<String>,
256 access: String,
258 },
259 ScaffoldFinished {
261 step: String,
263 state: String,
265 exit: Option<i32>,
267 duration_ms: u64,
269 },
270 Created {
272 path_digest: String,
274 mode: String,
276 stack: String,
278 format: String,
280 commit: String,
282 via: String,
284 },
285 Undone {
287 path_digest: String,
289 how: String,
291 via: String,
293 },
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298pub struct CollectionFile {
299 pub path: String,
301 pub state: speccoder_core::collection::FileState,
303 pub decided_by: Option<speccoder_core::collection::DecidedBy>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309#[serde(tag = "kind", rename_all = "snake_case")]
310pub enum OracleEvent {
311 Stage {
313 requirement: String,
315 stage: String,
317 n: u32,
319 of: u32,
321 },
322 Frozen {
324 unit: String,
326 requirement: String,
328 manifest_sha256: String,
330 approval_sha256: String,
332 visible_count: usize,
334 held_out_count: usize,
336 by: String,
338 via: String,
340 },
341 Lapsed {
343 unit: String,
345 requirement: String,
347 frozen_wording_sha256: String,
349 current_wording_sha256: String,
351 },
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
356pub struct StatementClass {
357 pub statement: String,
359 pub class: String,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
365pub struct ShownReproduction {
366 pub statement: String,
368 pub file: String,
370 pub test: String,
372 pub digest: String,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
378pub struct ShownSpecialCase {
379 pub statement: String,
381 pub rejected: bool,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum BugEvent {
389 Recorded {
391 home: String,
393 unit: String,
395 requirement: String,
397 triad_digest: String,
399 via: String,
401 },
402 ReproduceTry {
404 n: u32,
406 of: u32,
408 per_statement: Vec<StatementClass>,
410 },
411 Reproduced {
413 reproductions: Vec<ShownReproduction>,
415 },
416 ConfirmWaiting {
418 digest: String,
420 next: String,
422 },
423 Confirmed {
425 digest: String,
427 by: String,
429 via: String,
431 basis: String,
433 },
434 ConfirmRejected {
436 digest: String,
438 reason: String,
440 },
441 UnchangedSelected {
443 state: String,
445 count: usize,
447 },
448 Discrimination {
450 revert: String,
452 special_case: Vec<ShownSpecialCase>,
454 },
455 Outcome {
457 outcome: String,
459 verdict: Option<String>,
461 code: Option<String>,
463 next: Vec<String>,
465 },
466}
467
468impl BugEvent {
469 #[must_use]
472 pub const fn name(&self) -> &'static str {
473 match self {
474 Self::Recorded { .. } => "bug.recorded",
475 Self::ReproduceTry { .. } => "bug.reproduce_try",
476 Self::Reproduced { .. } => "bug.reproduced",
477 Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
478 Self::Confirmed { .. } => "bug.confirmed",
479 Self::ConfirmRejected { .. } => "bug.confirm_rejected",
480 Self::UnchangedSelected { .. } => "bug.unchanged_selected",
481 Self::Discrimination { .. } => "bug.discrimination",
482 Self::Outcome { .. } => "bug.outcome",
483 }
484 }
485
486 #[must_use]
488 pub fn line(&self) -> String {
489 match self {
490 Self::Recorded {
491 unit, requirement, ..
492 } => format!("bug recorded as {requirement} in {unit}"),
493 Self::ReproduceTry {
494 n,
495 of,
496 per_statement,
497 } => format!(
498 "reproduce try {n}/{of}: {}",
499 per_statement
500 .iter()
501 .map(|s| format!("{} {}", s.statement, s.class))
502 .collect::<Vec<_>>()
503 .join(", ")
504 ),
505 Self::Reproduced { reproductions } => format!(
506 "reproduced: {} test(s) fail on an assertion",
507 reproductions
508 .iter()
509 .filter(|r| r.statement.starts_with('E'))
510 .count()
511 ),
512 Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
513 Self::Confirmed { by, basis, .. } => {
514 format!("reproductions confirmed by {by} ({basis})")
515 }
516 Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
517 Self::UnchangedSelected { state, count } => {
518 format!("pass-to-pass: {count} test(s) ({state})")
519 }
520 Self::Discrimination {
521 revert,
522 special_case,
523 } => format!(
524 "revert {revert}; {} special case(s), {} rejected",
525 special_case.len(),
526 special_case.iter().filter(|s| s.rejected).count()
527 ),
528 Self::Outcome {
529 outcome, verdict, ..
530 } => match verdict {
531 Some(v) => format!("bug {outcome}: {v}"),
532 None => format!("bug {outcome}"),
533 },
534 }
535 }
536}
537
538pub trait Progress: Send + Sync + std::fmt::Debug {
540 fn event(&self, event: &Event);
542}
543
544#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
546pub struct LiveState {
547 pub pid: u32,
549 pub feature: String,
551 pub task: String,
553 pub title: String,
555 #[serde(default)]
557 pub stage: Option<Stage>,
558 #[serde(default)]
560 pub stage_detail: Option<String>,
561 pub last_call: Option<String>,
563 #[serde(default)]
565 pub said: Option<String>,
566 #[serde(default)]
568 pub thinking: Option<String>,
569 #[serde(default)]
571 pub recent: Vec<String>,
572 pub input_tokens: u64,
574 pub output_tokens: u64,
576 pub usd: Option<f64>,
578 pub finished: Option<String>,
580 pub started_at: u64,
582 pub updated_at: u64,
584}
585
586fn now() -> u64 {
587 std::time::SystemTime::now()
588 .duration_since(std::time::UNIX_EPOCH)
589 .map_or(0, |d| d.as_secs())
590}
591
592#[must_use]
594pub fn usd(
595 prices: Option<Prices>,
596 input: u64,
597 output: u64,
598 cache_read: u64,
599 cache_write: u64,
600) -> Option<f64> {
601 prices.map(|p| p.usd(input, output, cache_read, cache_write))
602}
603
604#[derive(Debug)]
606pub struct Live {
607 file: PathBuf,
608 prices: Option<Prices>,
609 sink: Option<Arc<dyn Progress>>,
610 state: Mutex<LiveState>,
611}
612
613impl Live {
614 #[must_use]
616 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
617 let _ = std::fs::create_dir_all(dir);
618 Self {
619 file: dir.join(FILE),
620 prices,
621 sink,
622 state: Mutex::new(LiveState {
623 pid: std::process::id(),
624 ..LiveState::default()
625 }),
626 }
627 }
628
629 fn write(&self, state: &LiveState) {
630 if let Ok(text) = serde_json::to_string_pretty(state) {
631 let tmp = self.file.with_extension("json.tmp");
632 if std::fs::write(&tmp, text).is_ok() {
633 let _ = std::fs::rename(&tmp, &self.file);
634 }
635 }
636 }
637}
638
639impl Progress for Live {
640 fn event(&self, event: &Event) {
641 let mut event = event.clone();
642 match &mut event {
644 Event::Usage {
645 input_tokens,
646 output_tokens,
647 cache_read_tokens,
648 cache_write_tokens,
649 usd: price,
650 ..
651 } => {
652 *price = usd(
653 self.prices,
654 *input_tokens,
655 *output_tokens,
656 *cache_read_tokens,
657 *cache_write_tokens,
658 )
659 }
660 Event::TaskFinished {
661 input_tokens,
662 output_tokens,
663 cache_read_tokens,
664 cache_write_tokens,
665 usd: price,
666 ..
667 } => {
668 *price = usd(
669 self.prices,
670 *input_tokens,
671 *output_tokens,
672 *cache_read_tokens,
673 *cache_write_tokens,
674 )
675 }
676 _ => {}
677 }
678 if let Ok(mut s) = self.state.lock() {
679 match &event {
680 Event::TaskStarted {
681 feature,
682 task,
683 title,
684 } => {
685 *s = LiveState {
686 pid: s.pid,
687 feature: feature.clone(),
688 task: task.clone(),
689 title: title.clone(),
690 started_at: now(),
691 ..LiveState::default()
692 };
693 }
694 Event::Stage {
695 stage,
696 state: StageState::Started,
697 detail,
698 ..
699 } => {
700 s.stage = Some(*stage);
701 s.stage_detail.clone_from(detail);
702 }
703 Event::Said { text, .. } => {
704 s.said = text
705 .lines()
706 .find(|l| !l.trim().is_empty())
707 .map(|l| l.chars().take(160).collect());
708 }
709 Event::Reasoning { text, .. } => {
711 s.thinking = text
712 .lines()
713 .rev()
714 .find(|l| !l.trim().is_empty())
715 .map(|l| l.chars().take(160).collect());
716 }
717 Event::Stage { .. }
718 | Event::Resumed { .. }
719 | Event::ApprovalRequested { .. }
720 | Event::ApprovalDecided { .. } => {}
721 Event::ToolCall {
722 turn,
723 tool,
724 target,
725 decision,
726 ..
727 } => {
728 let call = format!("{tool} {target} ({decision})");
729 s.recent.push(format!("turn {turn} {call}"));
730 if s.recent.len() > RECENT {
731 s.recent.remove(0);
732 }
733 s.last_call = Some(call);
734 }
735 Event::Usage {
736 input_tokens,
737 output_tokens,
738 usd,
739 ..
740 } => {
741 s.input_tokens = *input_tokens;
742 s.output_tokens = *output_tokens;
743 s.usd = *usd;
744 }
745 Event::TaskFinished {
746 outcome, message, ..
747 } => s.finished = Some(format!("{outcome}: {message}")),
748 Event::Notice { .. }
749 | Event::RunStarted { .. }
750 | Event::Bug { .. }
751 | Event::Oracle { .. }
752 | Event::CollectionChecked { .. }
753 | Event::RegressionBisected { .. }
754 | Event::PlanningStopped { .. }
755 | Event::Project { .. } => {}
756 }
757 s.updated_at = now();
758 self.write(&s);
759 }
760 if let Some(sink) = &self.sink {
761 sink.event(&event);
762 }
763 }
764}
765
766#[must_use]
768pub fn target(tool: &str, args: &serde_json::Value) -> String {
769 let text = match tool {
770 "run_command" => args["argv"]
771 .as_array()
772 .map(|a| {
773 a.iter()
774 .filter_map(|v| v.as_str())
775 .collect::<Vec<_>>()
776 .join(" ")
777 })
778 .unwrap_or_default(),
779 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
780 name if name.starts_with(crate::mcp::PREFIX) => args
782 .as_object()
783 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
784 .unwrap_or_default(),
785 _ => args["path"].as_str().unwrap_or_default().to_owned(),
786 };
787 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
788 if one.chars().count() > 60 {
789 format!("{}…", one.chars().take(59).collect::<String>())
790 } else {
791 one
792 }
793}
794
795#[must_use]
799pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
800 let root = match crate::inspect(path, None).data() {
801 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
802 _ => return None,
803 };
804 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
805 .ok()?;
806 let live: LiveState = serde_json::from_str(&text).ok()?;
807 let running = live.finished.is_none() && alive(live.pid);
808 Some((live, running))
809}
810
811pub fn alive(pid: u32) -> bool {
813 i32::try_from(pid)
814 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
815}
816
817pub fn status_with(path: &Path, state: &Path) -> CommandResult {
820 status_at(path, state, agentplane::core::Timestamp::now_utc())
821}
822
823#[doc(hidden)]
826pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
827 let inspected = crate::inspect(path, None);
829 let root = match inspected.data() {
830 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
831 _ => return CommandResult::carried("report", &inspected),
832 };
833 let plane_dir = crate::worktree::project_state_dir(state, &root);
834 let res = status_inner(&plane_dir);
835 let lines = waiting_lines(&plane_dir, now);
836 if lines.is_empty() {
837 return res;
838 }
839 let mut next = res.next().to_vec();
840 next.insert(
841 0,
842 speccoder_core::next::NextStep {
843 command: "speccoder report --approvals".to_owned(),
844 why: "see the waiting calls, then approve or reject them".to_owned(),
845 },
846 );
847 let message = format!("{}\n{}", res.message(), lines.join("\n"));
848 let status = res.status();
849 res.reworded(status, &message).with_next(next)
850}
851
852fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
856 let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
860 let rt = match crate::tokio_runtime() {
861 Ok(rt) => rt,
862 Err(e) => return unknown(e),
863 };
864 let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
865 Ok(Some(p)) => p,
866 Ok(None) => return Vec::new(),
867 Err(e) => return unknown(e.0),
868 };
869 let open = match rt.block_on(plane.open_tasks()) {
870 Ok(open) => open,
871 Err(e) => return unknown(e.0),
872 };
873 open.into_iter()
874 .filter(|t| crate::approvals::still_waits(t.due_at, now))
875 .filter_map(|t| {
876 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
877 let since = (now - t.created_at).whole_minutes().max(0);
878 let until = t
879 .due_at
880 .map(|d| {
881 format!(
882 ", denied as expired in {} h",
883 (d - now).whole_hours().max(0)
884 )
885 })
886 .unwrap_or_default();
887 Some(format!(
888 "waiting for a person: {} — {} (for {since} min{until})",
889 request.task,
890 request.covers()
891 ))
892 })
893 .collect()
894}
895
896fn status_inner(plane_dir: &Path) -> CommandResult {
897 let plane_dir = plane_dir.to_path_buf();
898 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
900 let mode = s.mode_line();
901 if !alive(s.pid) {
902 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
903 command: command.to_owned(),
904 why: why.to_owned(),
905 };
906 return CommandResult::new(
907 "status",
908 CommandStatus::Ok,
909 &format!(
910 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
911 s.feature, s.pid, s.branch
912 ),
913 )
914 .with_next(vec![
915 step("speccoder run --in-place", "resume where it stopped"),
916 step(
917 "speccoder run --in-place --return",
918 "save what it left and switch back without running",
919 ),
920 ]);
921 }
922 let res = status_live(&plane_dir);
923 let message = format!("{}\n{mode}", res.message());
924 let status = res.status();
925 return res.reworded(status, &message);
926 }
927 status_live(&plane_dir)
928}
929
930fn status_live(plane_dir: &Path) -> CommandResult {
932 let file = plane_dir.join(FILE);
933 let live: LiveState = match std::fs::read_to_string(&file)
934 .ok()
935 .and_then(|t| serde_json::from_str(&t).ok())
936 {
937 Some(l) => l,
938 None => {
939 return CommandResult::new(
940 "status",
941 CommandStatus::Ok,
942 "no run has been recorded for this project yet",
943 );
944 }
945 };
946 let ago = now().saturating_sub(live.updated_at);
947 let tokens = format!(
948 "{} in · {} out{}",
949 live.input_tokens,
950 live.output_tokens,
951 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
952 );
953 use speccoder_core::next::{Outcome, next};
954 let outcome = match (&live.finished, alive(live.pid)) {
955 (None, true) => Some(Outcome::Running),
956 (None, false) => Some(Outcome::Interrupted {
957 task: live.task.clone(),
958 }),
959 (Some(_), _) => None,
960 };
961 let message = match (&live.finished, alive(live.pid)) {
962 (None, true) => format!(
963 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
964 live.pid,
965 live.feature,
966 live.task,
967 stage_word(&live),
968 now().saturating_sub(live.started_at),
969 live.last_call.as_deref().unwrap_or("-"),
970 live.thinking
971 .as_deref()
972 .map(|s| format!("\nthe model thinks (a claim): {s}"))
973 .unwrap_or_default(),
974 live.said
975 .as_deref()
976 .map(|s| format!("\nthe model says (a claim): “{s}”"))
977 .unwrap_or_default(),
978 ),
979 (None, false) => format!(
980 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
981 live.feature,
982 live.task,
983 stage_word(&live),
984 live.pid
985 ),
986 (Some(outcome), _) => format!(
987 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
988 live.feature, live.task
989 ),
990 };
991 CommandResult::new("status", CommandStatus::Ok, &message)
992 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
993}
994
995#[must_use]
997pub fn stage_word(live: &LiveState) -> String {
998 match (live.stage, &live.stage_detail) {
999 (Some(st), Some(d)) => format!("{} {d}", st.name()),
1000 (Some(st), None) => st.name().to_owned(),
1001 (None, _) => "starting".to_owned(),
1002 }
1003}
1004
1005#[must_use]
1008pub fn tail(text: &str) -> Vec<String> {
1009 let lines: Vec<String> = text
1010 .lines()
1011 .map(|l| {
1012 l.chars()
1013 .filter(|c| !c.is_control())
1014 .take(200)
1015 .collect::<String>()
1016 })
1017 .filter(|l| !l.trim().is_empty())
1018 .collect();
1019 lines[lines.len().saturating_sub(5)..].to_vec()
1020}
1021
1022#[must_use]
1024pub fn status(path: &Path) -> CommandResult {
1025 match crate::worktree::state_dir() {
1026 Some(state) => status_with(path, &state),
1027 None => CommandResult::new(
1028 "status",
1029 CommandStatus::ConfigError,
1030 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1031 )
1032 .with_code("NO-STATE-DIR"),
1033 }
1034}