From 11de6e93d57055e7e10dfe9b6d80a81be752c7dd Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sun, 18 Oct 2026 11:35:29 -0400 Subject: [PATCH 1/3] Record Pass stages and dispatches, and show idle Passes (#770) --- README.md | 19 ++++- src/logs.rs | 32 ++++++- src/logs/command_record.rs | 79 +++++++++++++++-- src/logs/recording.rs | 70 ++++++++++----- src/logs/recording/tests.rs | 2 +- src/logs/status.rs | 164 ++++++++++++++++++++++++++---------- src/pass.rs | 7 +- src/run.rs | 2 +- src/run_ending.rs | 2 +- tests/architect_run.rs | 92 ++++++++++++++++++++ tests/security_run.rs | 76 +++++++++++++++++ tests/status.rs | 50 +++++++++++ 12 files changed, 510 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index ca3527aef..348328742 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,13 @@ The designed behaviour described in this README is implemented. Known gaps and p ## Machine status -`thirdshift status` names this host and lists live Runs started on an Issue URL across every repository in `logs.dir`, with their issue and title, Harness, Model, Effort, current stage and its age. A Command with no outcome whose process has gone shows as `died at `. Process start identifiers distinguish a reused process id on Linux and macOS. Completed Commands leave the live list; their Command records remain beside their Command logs, as `*.command.json`. A record write failure warns once per Command and does not change its outcome. +`thirdshift status` names this host and lists live Commands across every repository in `logs.dir`, with their issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Run or Spec run they dispatch beneath themselves. A Pass with no issue is named by its kind and Command log stamp, such as `architect-20261018T120000-0400` or `secure-20261018T120000-0400`. A Command with no outcome whose process has gone shows as `died at `. Process start identifiers distinguish a reused process id on Linux and macOS. Completed Commands leave the live list; their Command records remain beside their Command logs, as `*.command.json`. A record write failure warns once per Command and does not change its outcome. + +For each repository and kind of Pass whose latest Activity log line is a skip, status also shows that Pass as idle with its reason, even when the skip is older than another kind's activity. A later start or end for that kind clears its idle entry. For example: + +```text +Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: https://github.com/acme/widgets/issues/7 +``` `thirdshift status --json` is the versioned API for agents and the coming [Console](docs/adr/0016-the-console-is-a-long-running-reader.md). It runs outside a repository too. Version 1 has this shape: @@ -83,6 +89,7 @@ The designed behaviour described in this README is implemented. Known gaps and p "version": 1, "kind": "Run", "repository": "acme/widgets", + "name": "acme/widgets#7", "issue": {"number": 7, "url": "https://github.com/acme/widgets/issues/7", "title": "Make widgets"}, "hostname": "factory", "process_id": 1234, @@ -97,13 +104,21 @@ The designed behaviour described in this README is implemented. Known gaps and p "pull_request": null, "outcome": null, "cause": null, + "dispatched": [], "state": "live", "stage": {"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "age_seconds": 52} + }], + "idle_passes": [{ + "repository": "acme/widgets", + "kind": "Pickup run", + "reason": "no Ready issue on acme/widgets" }] } ``` -Times are RFC 3339; ages are nonnegative seconds. `model` and `effort` are null when left to the Harness. `session_log`, `issue_branch`, `pull_request`, `outcome` and `cause` are nullable. The retained record has the same Command fields without the computed `state` and `stage`; it ends with its outcome, failure cause (null on success), and the Issue branch and pull request left behind. `state` is `live` or `died`; `stage` is null before the first known stage. Readers skip unknown stage names. Known stages are `implement`, `Resume`, `Security review`, `pull request`, `CI watch`, `Repair`, `Self-merge` and `cleanup`. More Command kinds and history are tracked in [#711](https://github.com/JacobStephens2/thirdshift/issues/711). +Times are RFC 3339; ages are nonnegative seconds. `model` and `effort` are null when left to the Harness. `issue` is null for a Pass with no issue; `session_log`, `issue_branch`, `pull_request`, `outcome` and `cause` are nullable. Command kinds are `Run`, `Spec run`, `Architect run`, `Pickup run` and `Security run`. `name` identifies an issue or a Pass's kind and stamp. `dispatched` is an array of the Run or Spec run a Pass dispatched, with the same fields as its parent (and an empty `dispatched` array); it shares the Pass's process identity. Its `state` may also be `ended`, and it retains its own outcome, cause, Issue branch and pull request. The retained Command record has the same fields, including dispatched work, without the computed `state` and `stage` or empty leaf `dispatched` arrays. `idle_passes` contains one entry per repository and kind whose latest Activity log line is a skip; it is independent of `commands`, and never creates a Command record or log for a skipped Pass. + +`state` is `live` or `died` for an unfinished Command; `stage` is null before the first known stage. Readers skip unknown stage names. Known stages are `implement`, `Resume`, `Security review`, `pull request`, `CI watch`, `Repair`, `Self-merge`, `cleanup`, `Tickets`, `Spec review`, `Architecture review`, `Security audit`, `Security reproduction` and `Security fix publishing`. A Pass retains its own stages while its dispatched work records its stages separately. History and the remaining Command views are tracked in [#711](https://github.com/JacobStephens2/thirdshift/issues/711). ## Install diff --git a/src/logs.rs b/src/logs.rs index 8842521c8..9bc5aaf55 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -137,9 +137,8 @@ pub fn configured(config: &UserConfig) { /// Command log, showing any lines held from the terminal, with a line naming /// the Harness, Model and Effort, then write the Activity log's line that it /// started, naming that log and those. Only the outermost command's first -/// call does anything: the work a Pass started -/// covers the Run it dispatches, and a child Run records nothing, as the -/// command that started it does. +/// call keeps the logs. A Pass also records the Run or Spec run it dispatches +/// beneath itself; a child process keeps no Command log of its own. pub fn started(work: Work, harness: &Choice) { record().started(work, harness); } @@ -172,7 +171,16 @@ pub fn stage(stage: Stage) { /// Keep the current Session log, including a Resume's own log. pub fn session(kind: &str, path: &Path) { - let stage = if kind.ends_with("-resume") { + let original = kind.strip_suffix("-resume").unwrap_or(kind); + let stage = if original == "architecture-review" { + Stage::ArchitectureReview + } else if original == "security-audit" { + Stage::SecurityAudit + } else if original.starts_with("security-reproduction-") { + Stage::SecurityReproduction + } else if original == "security-fix-publishing" { + Stage::SecurityFixPublishing + } else if kind.ends_with("-resume") { Stage::Resume } else if kind.starts_with("repair-") { Stage::Repair @@ -180,6 +188,8 @@ pub fn session(kind: &str, path: &Path) { Stage::SecurityReview } else if kind == "implement" { Stage::Implement + } else if kind == "spec-review" { + Stage::SpecReview } else { return; }; @@ -196,6 +206,11 @@ pub fn finished(account: &crate::run_ending::Account<'_>) { record().finished(account); } +/// Finish a Pass's dispatched Run without ending its owning Command. +pub fn dispatch_ended(ended: &crate::run::Ended) { + record().dispatch_ended(&crate::run_ending::Account::of_run(ended)); +} + /// Print the lines held from the terminal, as before a failure, and print /// every line from here on. pub fn show_held() { @@ -255,6 +270,15 @@ fn root_under(logs_dir: &Path, repo: &Repo) -> PathBuf { } impl Work<'_> { + fn initial_stage(self) -> Option { + match self { + Work::Run(_) => Some(Stage::Implement), + Work::SpecRun(_) => Some(Stage::Tickets), + Work::ArchitectRun(_) => Some(Stage::ArchitectureReview), + Work::PickupRun(_) | Work::SecurityRun(_) => None, + } + } + /// The repository whose work it records. fn repo(self) -> Repo { match self { diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs index 903e20771..a6fce0d22 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -5,15 +5,35 @@ use std::path::PathBuf; use chrono::{DateTime, FixedOffset}; use serde::{Deserialize, Serialize}; +use super::Work; use crate::harness::Choice; -use crate::issue::IssueUrl; + +/// A Pass owns its dispatched work in the same process and atomic record. +#[derive(Serialize, Deserialize)] +pub(super) struct CommandTree { + #[serde(flatten)] + pub command: CommandRecord, + #[serde(default)] + pub dispatched: Vec, +} + +impl CommandTree { + pub fn active(&mut self) -> &mut CommandRecord { + match self.dispatched.last_mut() { + Some(dispatched) if dispatched.outcome.is_none() => dispatched, + _ => &mut self.command, + } + } +} #[derive(Serialize, Deserialize)] pub(super) struct CommandRecord { pub version: u32, pub kind: String, pub repository: String, - pub issue: Issue, + #[serde(default)] + pub name: String, + pub issue: Option, pub hostname: String, pub process_id: u32, pub process_start: String, @@ -54,6 +74,12 @@ pub enum StageName { Repair, SelfMerge, Cleanup, + ArchitectureReview, + Tickets, + SpecReview, + SecurityAudit, + SecurityReproduction, + SecurityFixPublishing, } impl StageName { @@ -67,6 +93,12 @@ impl StageName { Self::Repair => "Repair", Self::SelfMerge => "Self-merge", Self::Cleanup => "cleanup", + Self::ArchitectureReview => "Architecture review", + Self::Tickets => "Tickets", + Self::SpecReview => "Spec review", + Self::SecurityAudit => "Security audit", + Self::SecurityReproduction => "Security reproduction", + Self::SecurityFixPublishing => "Security fix publishing", } } @@ -80,6 +112,12 @@ impl StageName { Self::Repair, Self::SelfMerge, Self::Cleanup, + Self::ArchitectureReview, + Self::Tickets, + Self::SpecReview, + Self::SecurityAudit, + Self::SecurityReproduction, + Self::SecurityFixPublishing, ] .into_iter() .any(|stage| stage.name() == name) @@ -93,16 +131,29 @@ impl CommandRecord { && outside.process_start(self.process_id).as_deref() == Some(&self.process_start) } - pub fn new(issue: &IssueUrl, harness: &Choice, now: DateTime) -> Self { - Self { - version: 1, - kind: "Run".into(), - repository: issue.repo_slug(), - issue: Issue { + pub fn new(work: Work<'_>, stamp: &str, harness: &Choice, now: DateTime) -> Self { + let issue = match work { + Work::Run(issue) | Work::SpecRun(issue) | Work::PickupRun(issue) => Some(Issue { number: issue.number, url: issue.url.clone(), title: None, - }, + }), + Work::ArchitectRun(_) | Work::SecurityRun(_) => None, + }; + let name = match work { + Work::ArchitectRun(_) => format!("architect-{stamp}"), + Work::SecurityRun(_) => format!("secure-{stamp}"), + Work::PickupRun(_) => format!("pickup-{stamp}"), + Work::Run(issue) | Work::SpecRun(issue) => { + format!("{}#{}", issue.repo_slug(), issue.number) + } + }; + Self { + version: 1, + kind: work.kind().to_string(), + repository: work.repo().slug(), + name, + issue, hostname: String::new(), process_id: std::process::id(), process_start: String::new(), @@ -126,4 +177,14 @@ impl CommandRecord { .rev() .find(|stage| StageName::known(&stage.name)) } + + pub fn finish(&mut self, account: &crate::run_ending::Account<'_>) { + self.outcome = Some(account.outcome.into()); + self.cause = account + .ended + .as_ref() + .err() + .map(|cause| cause.full().into()); + self.pull_request = account.pr_url.map(String::from); + } } diff --git a/src/logs/recording.rs b/src/logs/recording.rs index 671aab970..9c98b72b0 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{Context, Result}; use super::activity::{self, Kind}; -use super::command_record::{CommandRecord, Stage, StageName}; +use super::command_record::{CommandRecord, CommandTree, Stage, StageName}; use super::effects::{Outside, Stream}; use super::{Begin, CommandKind, Pass, Work, root_under}; use crate::config::UserConfig; @@ -31,7 +31,7 @@ pub(super) struct Record { command_log: Log, activity_started: Option, activity_warned: bool, - command_record: Option<(PathBuf, CommandRecord)>, + command_record: Option<(PathBuf, CommandTree)>, record_warned: bool, } @@ -122,17 +122,27 @@ impl Record { let first = !self.started; self.started = true; if !first || self.child { + if !self.child + && self.command != CommandKind::Issue + && matches!(work, Work::Run(_) | Work::SpecRun(_)) + { + let stamp = self.stamp(); + let mut facts = CommandRecord::new(work, &stamp, harness, self.outside.now()); + if let Some((_, tree)) = &mut self.command_record { + facts.hostname = tree.command.hostname.clone(); + facts.process_start = tree.command.process_start.clone(); + tree.dispatched.push(facts); + self.stage(work.initial_stage().unwrap(), None); + } + } return; } let root = self.root(&work.repo()); let stamp = self.stamp(); let began = matches!(self.command_log, Log::Held { .. }); self.keep(work.command_log(&root, &stamp)); - if began - && self.command == CommandKind::Issue - && let Work::Run(issue) = work - { - let mut facts = CommandRecord::new(issue, harness, self.outside.now()); + if began { + let mut facts = CommandRecord::new(work, &stamp, harness, self.outside.now()); facts.hostname = self.outside.hostname(); facts.process_start = self .outside @@ -150,8 +160,18 @@ impl Record { let path = work .command_log(&root, &stamp) .with_extension(format!("{owner}.command.json")); - self.command_record = Some((path, facts)); - self.stage(StageName::Implement, None); + self.command_record = Some(( + path, + CommandTree { + command: facts, + dispatched: Vec::new(), + }, + )); + if let Some(stage) = work.initial_stage() { + self.stage(stage, None); + } else { + self.write_record(); + } } self.step(format_args!("sessions run on {harness}")); let command_log = match self.command_log_path() { @@ -193,24 +213,27 @@ impl Record { } pub(super) fn issue_title(&mut self, title: Option) { - if let Some((_, facts)) = &mut self.command_record { - facts.issue.title = title; + if let Some((_, tree)) = &mut self.command_record + && let Some(issue) = &mut tree.active().issue + { + issue.title = title; self.write_record(); } } pub(super) fn issue_branch(&mut self, branch: Option<&str>) { - if let Some((_, facts)) = &mut self.command_record { - facts.issue_branch = branch.map(String::from); + if let Some((_, tree)) = &mut self.command_record { + tree.active().issue_branch = branch.map(String::from); self.write_record(); } } pub(super) fn stage(&mut self, stage: StageName, log: Option<&Path>) { let name = stage.name(); - let Some((_, facts)) = &mut self.command_record else { + let Some((_, tree)) = &mut self.command_record else { return; }; + let facts = tree.active(); if let Some(log) = log { facts.session_log = Some(log.into()); } @@ -227,14 +250,17 @@ impl Record { } pub(super) fn finished(&mut self, account: &crate::run_ending::Account<'_>) { - if let Some((_, facts)) = &mut self.command_record { - facts.outcome = Some(account.outcome.into()); - facts.cause = account - .ended - .as_ref() - .err() - .map(|cause| cause.full().into()); - facts.pull_request = account.pr_url.map(String::from); + if let Some((_, tree)) = &mut self.command_record { + tree.command.finish(account); + self.write_record(); + } + } + + pub(super) fn dispatch_ended(&mut self, account: &crate::run_ending::Account<'_>) { + if let Some((_, tree)) = &mut self.command_record + && let Some(dispatched) = tree.dispatched.last_mut() + { + dispatched.finish(account); self.write_record(); } } diff --git a/src/logs/recording/tests.rs b/src/logs/recording/tests.rs index 9da2afd65..eac6a4218 100644 --- a/src/logs/recording/tests.rs +++ b/src/logs/recording/tests.rs @@ -269,7 +269,7 @@ fn commands_started_on_the_same_issue_in_one_second_keep_separate_records() { .collect(); let mut titles: Vec<_> = records .iter() - .map(|record| record.issue.title.as_deref().unwrap()) + .map(|record| record.issue.as_ref().unwrap().title.as_deref().unwrap()) .collect(); titles.sort(); assert_eq!(titles, ["first Command", "second Command"]); diff --git a/src/logs/status.rs b/src/logs/status.rs index a8c6c9a18..04cc2e85d 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -7,8 +7,9 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Local}; use serde::Serialize; -use super::command_record::CommandRecord; +use super::command_record::{CommandRecord, CommandTree}; use super::effects::OnMachine; +use super::{Pass, activity}; use crate::config::UserConfig; #[derive(Serialize)] @@ -16,6 +17,14 @@ struct Status { version: u32, hostname: String, commands: Vec, + idle_passes: Vec, +} + +#[derive(Serialize)] +struct IdlePass { + repository: String, + kind: String, + reason: String, } #[derive(Serialize)] @@ -24,6 +33,7 @@ struct Command { record: CommandRecord, state: &'static str, stage: Option, + dispatched: Vec, } #[derive(Serialize)] @@ -33,12 +43,71 @@ struct CurrentStage { age_seconds: i64, } +impl Command { + fn new(record: CommandRecord, now: DateTime) -> Self { + let state = if record.outcome.is_some() { + "ended" + } else if record.live(&mut OnMachine) { + "live" + } else { + "died" + }; + let stage = record.current_stage().map(|stage| CurrentStage { + name: stage.name.clone(), + began_at: stage.began_at.clone(), + age_seconds: DateTime::parse_from_rfc3339(&stage.began_at) + .map(|began| now.signed_duration_since(began).num_seconds().max(0)) + .unwrap_or(0), + }); + Self { + record, + state, + stage, + dispatched: Vec::new(), + } + } + + fn print(&self, indent: &str) { + let facts = &self.record; + let stage = self.stage.as_ref().map_or_else( + || "starting".into(), + |stage| { + if self.state == "died" { + format!("died at {}", stage.name) + } else { + format!("{} · {}", stage.name, age(stage.age_seconds)) + } + }, + ); + let subject = match &facts.issue { + Some(issue) => format!( + "{}#{} {}", + facts.repository, + issue.number, + issue.title.as_deref().unwrap_or("(title unavailable)") + ), + None => format!("{} on {}", facts.name, facts.repository), + }; + println!( + "{indent}{subject} · {} · {} · {} · {stage}", + facts.harness, + facts.model.as_deref().unwrap_or("default Model"), + facts.effort.as_deref().unwrap_or("default Effort") + ); + for dispatched in &self.dispatched { + dispatched.print(&format!("{indent} ")); + } + } +} + pub fn show(json: bool) -> Result<()> { let config = UserConfig::load()?; let now = Local::now(); let mut commands = Vec::new(); + let mut idle_passes = Vec::new(); for owner in directories(&config.logs_dir)? { for repo in directories(&owner)? { + idle_passes.extend(read_idle_passes(&repo)?); for folder in directories(&repo.join("commands"))? { for entry in entries(&folder)? { if !entry @@ -51,29 +120,20 @@ pub fn show(json: bool) -> Result<()> { let path = entry.path(); let bytes = fs::read(&path) .with_context(|| format!("could not read {}", path.display()))?; - let Ok(record) = serde_json::from_slice::(&bytes) else { + let Ok(tree) = serde_json::from_slice::(&bytes) else { continue; }; + let record = tree.command; if record.version != 1 || record.outcome.is_some() { continue; } - let state = if record.live(&mut OnMachine) { - "live" - } else { - "died" - }; - let stage = record.current_stage().map(|stage| CurrentStage { - name: stage.name.clone(), - began_at: stage.began_at.clone(), - age_seconds: DateTime::parse_from_rfc3339(&stage.began_at) - .map(|began| now.signed_duration_since(began).num_seconds().max(0)) - .unwrap_or(0), - }); - commands.push(Command { - record, - state, - stage, - }); + let mut command = Command::new(record, now); + command.dispatched = tree + .dispatched + .into_iter() + .map(|record| Command::new(record, now)) + .collect(); + commands.push(command); } } } @@ -90,45 +150,61 @@ pub fn show(json: bool) -> Result<()> { &b.record.started_at, )) }); + idle_passes.sort_by(|a, b| (&a.repository, &a.kind).cmp(&(&b.repository, &b.kind))); let status = Status { version: 1, hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), commands, + idle_passes, }; if json { println!("{}", serde_json::to_string_pretty(&status)?); } else { println!("{}", status.hostname); - for command in status.commands { - let facts = command.record; - let stage = command.stage.map_or_else( - || "starting".into(), - |stage| { - if command.state == "died" { - format!("died at {}", stage.name) - } else { - format!("{} · {}", stage.name, age(stage.age_seconds)) - } - }, - ); - println!( - "{}#{} {} · {} · {} · {} · {stage}", - facts.repository, - facts.issue.number, - facts - .issue - .title - .as_deref() - .unwrap_or("(title unavailable)"), - facts.harness, - facts.model.as_deref().unwrap_or("default Model"), - facts.effort.as_deref().unwrap_or("default Effort") - ); + for command in &status.commands { + command.print(""); + } + for pass in status.idle_passes { + println!("{} idle on {}: {}", pass.kind, pass.repository, pass.reason); } } Ok(()) } +fn read_idle_passes(repo: &Path) -> Result> { + let path = repo.join(activity::FILE); + let activity = match fs::read_to_string(&path) { + Ok(activity) => activity, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error).with_context(|| format!("could not read {}", path.display())); + } + }; + let repository = format!( + "{}/{}", + repo.parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy(), + repo.file_name().unwrap().to_string_lossy() + ); + let mut idle = Vec::new(); + for pass in [Pass::Architect, Pass::Pickup, Pass::Security] { + let kind = activity::Kind::Pass(pass); + if let Some(line) = activity::last_of(&activity, kind) + && let Some(reason) = line.strip_prefix(&format!("{kind} skipped: ")) + { + idle.push(IdlePass { + repository: repository.clone(), + kind: kind.to_string(), + reason: reason.into(), + }); + } + } + Ok(idle) +} + fn age(seconds: i64) -> String { if seconds < 60 { format!("{seconds} sec") diff --git a/src/pass.rs b/src/pass.rs index f148fb802..7a5623cf2 100644 --- a/src/pass.rs +++ b/src/pass.rs @@ -235,6 +235,7 @@ impl Outside for LaunchAndGitHub<'_> { } fn audit(&mut self, base: &str) -> (Result, Option) { + logs::stage(logs::Stage::SecurityAudit); crate::security::audit::run(self.launch.git(), self.repo, base, self.harness) } @@ -262,6 +263,7 @@ impl Outside for LaunchAndGitHub<'_> { record: &SecurityRecord, number: usize, ) -> (Result, Option) { + logs::stage(logs::Stage::SecurityReproduction); crate::security::reproduction::run( self.launch.git(), self.repo, @@ -307,7 +309,9 @@ impl Outside for LaunchAndGitHub<'_> { harness: self.harness.clone(), ..asks }; - run::run_to_end(issue, &mut asks, StartedBy::Dispatch { base }) + let ended = run::run_to_end(issue, &mut asks, StartedBy::Dispatch { base }); + logs::dispatch_ended(&ended); + ended } fn publish_security_fix( @@ -316,6 +320,7 @@ impl Outside for LaunchAndGitHub<'_> { record: &SecurityRecord, url: &str, ) -> (Result, Option) { + logs::stage(logs::Stage::SecurityFixPublishing); crate::security::fixing::publish( self.launch.git(), self.repo, diff --git a/src/run.rs b/src/run.rs index 1d7716094..4bd1fbc13 100644 --- a/src/run.rs +++ b/src/run.rs @@ -190,7 +190,7 @@ fn run( started_by, directory.checked_out(), ); - if matches!(started_by, StartedBy::Command) { + if matches!(started_by, StartedBy::Command | StartedBy::Dispatch { .. }) { outside.record_branch_left(); } outcome diff --git a/src/run_ending.rs b/src/run_ending.rs index bd3eb8cf3..12efcf89b 100644 --- a/src/run_ending.rs +++ b/src/run_ending.rs @@ -171,7 +171,7 @@ impl Cause { impl<'a> Account<'a> { /// A Run or a Spec run that `ended`. - fn of_run(ended: &'a Ended) -> Self { + pub(crate) fn of_run(ended: &'a Ended) -> Self { let base_fix = ended.base_fix.as_deref(); match &ended.outcome { Ok(reached) => Account { diff --git a/tests/architect_run.rs b/tests/architect_run.rs index f3f83176b..90317ea93 100644 --- a/tests/architect_run.rs +++ b/tests/architect_run.rs @@ -1766,6 +1766,98 @@ fn release(scenario: &Scenario) { fs::write(scenario.path("release"), "").unwrap(); } +#[test] +fn status_names_a_live_architecture_review_by_kind_and_command_stamp() { + let scenario = scenario(); + scenario.agent_does(&format!( + "{AGENT_WAITS_FOR_RELEASE}{AGENT_PUBLISHES_A_TICKET}" + )); + let held = scenario.run_until(&["architect", "--plan-only"], &[], "started"); + let result = scenario.run(&["status", "--json"]); + let text = scenario.run(&["status"]); + release(&scenario); + let ended = held.finish(); + assert_eq!(ended.code, Some(0), "{}", ended.stderr); + let status: serde_json::Value = serde_json::from_str(&result.stdout).unwrap(); + let command = &status["commands"][0]; + assert_eq!(command["kind"], "Architect run", "{status}"); + assert_eq!(command["issue"], serde_json::Value::Null); + assert_eq!(command["state"], "live"); + assert_eq!(command["stage"]["name"], "Architecture review"); + assert!(command["stage"]["age_seconds"].is_number()); + let log = scenario.log_files( + "home/.thirdshift/logs/acme/widgets/commands/architect", + "log", + ); + let name = format!("architect-{}", log[0].strip_suffix(".log").unwrap()); + assert_eq!(command["name"], name); + assert!(text.stdout.contains(&name), "{}", text.stdout); + assert!( + text.stdout.contains("Architecture review ·"), + "{}", + text.stdout + ); +} + +#[test] +fn status_nests_the_architect_plans_run_or_spec_run_under_its_pass() { + for (plan, held_issue, base, kind, stage) in [ + ( + single_ticket_plan as fn() -> Scenario, + 8, + "main", + "Run", + "implement", + ), + (|| spec_plan(""), 9, "issue-8", "Spec run", "Tickets"), + ] { + let scenario = plan(); + scenario.agent_does_for( + held_issue, + &format!( + "{AGENT_WAITS_FOR_RELEASE}{}", + agent_opens_pr(held_issue, base) + ), + ); + let held = scenario.run_until(&["architect"], &[], "started"); + let result = scenario.run(&["status", "--json"]); + let text = scenario.run(&["status"]); + release(&scenario); + let ended = held.finish(); + assert_eq!(ended.code, Some(0), "{}", ended.stderr); + let status: serde_json::Value = serde_json::from_str(&result.stdout).unwrap(); + assert_eq!(status["commands"].as_array().unwrap().len(), 1, "{status}"); + let pass = &status["commands"][0]; + assert_eq!(pass["kind"], "Architect run"); + assert_eq!(pass["issue"], serde_json::Value::Null); + let dispatched = &pass["dispatched"][0]; + assert_eq!(dispatched["kind"], kind, "{status}"); + assert_eq!(dispatched["issue"]["number"], 8); + assert_eq!(dispatched["stage"]["name"], stage); + assert!(dispatched["stage"]["age_seconds"].is_number()); + assert!( + text.stdout.contains("\n acme/widgets#8"), + "{}", + text.stdout + ); + let folder = "home/.thirdshift/logs/acme/widgets/commands/architect"; + let names = scenario.entries(folder); + let record = names + .iter() + .find(|name| name.ends_with(".command.json")) + .unwrap(); + let record: serde_json::Value = + serde_json::from_slice(&fs::read(scenario.path(folder).join(record)).unwrap()).unwrap(); + assert_eq!(record["outcome"], "ready for review"); + assert_eq!(record["dispatched"][0]["outcome"], "ready for review"); + assert_eq!(record["dispatched"][0]["issue_branch"], "issue-8"); + assert_eq!( + record["dispatched"][0]["pull_request"], + pr_from(&scenario, "issue-8")["url"] + ); + } +} + #[test] fn a_second_architect_run_on_the_repository_is_skipped_while_the_first_is_mid_review() { let scenario = scenario(); diff --git a/tests/security_run.rs b/tests/security_run.rs index a4614e8aa..dacaa2eb7 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -414,6 +414,82 @@ printf 'Proposed public issue:\nTitle: Bound accepted input\nBody:\nReject overs ) } +#[test] +fn status_tracks_security_stages_and_nests_the_dispatched_fix() { + for (held_session, stage) in [ + (1, "Security audit"), + (2, "Security reproduction"), + (3, "Security fix publishing"), + (4, "implement"), + ] { + let scenario = Scenario::new(); + scenario.repo_has_labels(&["needs-triage", "ready-for-agent"]); + let scripts = [ + audit_script(&json!([finding("status-stages")]).to_string()), + reproduction_script("reproduced high single"), + stage_fix("GHSA-test-test-0001"), + implement_fix().into(), + ]; + for (index, script) in scripts.iter().enumerate() { + let hold = if index + 1 == held_session { + "root=$(dirname \"$FAKE_CLAUDE_RECORD\")\ntouch \"$root/started\"\nwhile [ -d \"$root\" ] && [ ! -e \"$root/release\" ]; do sleep 0.05; done\n" + } else { + "" + }; + scenario.agent_does_in_session(index + 1, &format!("{hold}{script}")); + } + let held = scenario.run_until(&["secure", "security-fix"], &[], "started"); + let result = scenario.run(&["status", "--json"]); + let text = scenario.run(&["status"]); + fs::write(scenario.path("release"), "").unwrap(); + let ended = held.finish(); + assert_eq!(ended.code, Some(0), "{}", ended.stderr); + let status: Value = serde_json::from_str(&result.stdout).unwrap(); + let pass = &status["commands"][0]; + assert_eq!(pass["kind"], "Security run", "{status}"); + assert_eq!(pass["issue"], Value::Null); + let active = if held_session == 4 { + &pass["dispatched"][0] + } else { + pass + }; + assert_eq!(active["stage"]["name"], stage, "{status}"); + assert!(active["stage"]["age_seconds"].is_number()); + if held_session == 4 { + assert_eq!(active["issue"]["number"], 8); + assert!( + text.stdout.contains("\n acme/widgets#8"), + "{}", + text.stdout + ); + } + let folder = "home/.thirdshift/logs/acme/widgets/commands/secure"; + let names = scenario.entries(folder); + let record = names + .iter() + .find(|name| name.ends_with(".command.json")) + .unwrap(); + let record: Value = + serde_json::from_slice(&fs::read(scenario.path(folder).join(record)).unwrap()).unwrap(); + let stages: Vec<_> = record["stages"] + .as_array() + .unwrap() + .iter() + .map(|stage| stage["name"].as_str().unwrap()) + .collect(); + assert_eq!( + stages, + [ + "Security audit", + "Security reproduction", + "Security fix publishing" + ] + ); + assert_eq!(record["outcome"], "ready for review"); + assert_eq!(record["dispatched"][0]["outcome"], "ready for review"); + } +} + #[test] fn public_fix_publishing_cannot_write_github_content_with_any_harness() { for harness in ["claude", "codex", "agy", "grok", "muse", "opencode"] { diff --git a/tests/status.rs b/tests/status.rs index e43a5edee..6eb73d691 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -7,6 +7,56 @@ use support::Scenario; const LOGS: &str = "home/.thirdshift/logs/acme/widgets/commands/issue"; const OPENS_PR: &str = "echo work > work.txt\ngit add work.txt\ngit commit -qm 'Make widgets'\ngh pr create --base main --head issue-7 --title 'Make widgets' --body 'Closes #7'\n"; +#[test] +fn status_shows_each_idle_pass_until_its_own_kind_starts_work() { + let scenario = Scenario::new(); + scenario.user_config_is("[activity]\nquiet_skips = true\n"); + for _ in 0..2 { + let skipped = scenario.run(&["pickup"]); + assert_eq!(skipped.code, Some(0), "{}", skipped.stderr); + } + scenario.issue_titled(7, "Make widgets"); + scenario.issue_labelled(7, &["ready-for-agent"]); + for kind in ["architect", "secure"] { + let skipped = scenario.run(&[kind]); + assert_eq!(skipped.code, Some(0), "{}", skipped.stderr); + } + let result = scenario.run(&["status", "--json"]); + let text = scenario.run(&["status"]); + let status: Value = serde_json::from_str(&result.stdout).unwrap(); + assert_eq!(status["commands"], serde_json::json!([])); + let idle = status["idle_passes"].as_array().expect(&result.stdout); + assert_eq!(idle.len(), 3, "{status}"); + assert!( + idle.iter().any(|pass| pass["kind"] == "Pickup run" + && pass["reason"] == "no Ready issue on acme/widgets") + ); + for kind in ["Architect run", "Security run"] { + let reason = + "Ready issue #7 \"Make widgets\" goes first: https://github.com/acme/widgets/issues/7"; + assert!( + idle.iter().any(|pass| pass["repository"] == "acme/widgets" + && pass["kind"] == kind + && pass["reason"] == reason), + "{status}" + ); + assert!( + text.stdout + .contains(&format!("{kind} idle on acme/widgets: {reason}")), + "{}", + text.stdout + ); + } + scenario.agent_does(OPENS_PR); + let ended = scenario.run(&["pickup"]); + assert_eq!(ended.code, Some(0), "{}", ended.stderr); + let result = scenario.run(&["status", "--json"]); + let status: Value = serde_json::from_str(&result.stdout).unwrap(); + let idle = status["idle_passes"].as_array().unwrap(); + assert_eq!(idle.len(), 2, "{status}"); + assert!(!idle.iter().any(|pass| pass["kind"] == "Pickup run")); +} + fn retained(scenario: &Scenario) -> Value { let files: Vec<_> = scenario .entries(LOGS) From 22868a4d813b173f4a2ec2bcc3e39bdbee5b9301 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sun, 18 Oct 2026 11:41:34 -0400 Subject: [PATCH 2/3] Keep failed dispatches and address independent review findings --- src/logs.rs | 5 +++++ src/logs/recording.rs | 25 +++++++++++++++++-------- src/logs/recording/tests.rs | 5 +---- src/pass.rs | 12 +++++++++++- tests/architect_run.rs | 33 ++++++++++++++++++++++++++------- tests/security_run.rs | 8 +------- tests/status.rs | 28 +++++++++------------------- tests/support/mod.rs | 11 +++++++++++ 8 files changed, 81 insertions(+), 46 deletions(-) diff --git a/src/logs.rs b/src/logs.rs index 9bc5aaf55..0f18951ca 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -211,6 +211,11 @@ pub fn dispatch_ended(ended: &crate::run::Ended) { record().dispatch_ended(&crate::run_ending::Account::of_run(ended)); } +/// Keep a Pass's dispatch before the Run's fallible launch checks. +pub fn dispatching(work: Work, harness: &Choice) { + record().dispatching(work, harness); +} + /// Print the lines held from the terminal, as before a failure, and print /// every line from here on. pub fn show_held() { diff --git a/src/logs/recording.rs b/src/logs/recording.rs index 9c98b72b0..cdd7bb04d 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -125,15 +125,13 @@ impl Record { if !self.child && self.command != CommandKind::Issue && matches!(work, Work::Run(_) | Work::SpecRun(_)) + && let Some((_, tree)) = &mut self.command_record + && let Some(dispatched) = tree.dispatched.last_mut() { - let stamp = self.stamp(); - let mut facts = CommandRecord::new(work, &stamp, harness, self.outside.now()); - if let Some((_, tree)) = &mut self.command_record { - facts.hostname = tree.command.hostname.clone(); - facts.process_start = tree.command.process_start.clone(); - tree.dispatched.push(facts); - self.stage(work.initial_stage().unwrap(), None); - } + // The launch checks have now established whether the issue is + // a Spec. The dispatch itself was kept before those checks. + dispatched.kind = work.kind().to_string(); + self.stage(work.initial_stage().unwrap(), None); } return; } @@ -265,6 +263,17 @@ impl Record { } } + pub(super) fn dispatching(&mut self, work: Work, harness: &Choice) { + let stamp = self.stamp(); + let mut facts = CommandRecord::new(work, &stamp, harness, self.outside.now()); + if let Some((_, tree)) = &mut self.command_record { + facts.hostname = tree.command.hostname.clone(); + facts.process_start = tree.command.process_start.clone(); + tree.dispatched.push(facts); + self.write_record(); + } + } + fn write_record(&mut self) { let Some((path, facts)) = &self.command_record else { return; diff --git a/src/logs/recording/tests.rs b/src/logs/recording/tests.rs index eac6a4218..15e568277 100644 --- a/src/logs/recording/tests.rs +++ b/src/logs/recording/tests.rs @@ -374,10 +374,7 @@ fn each_work_kind_records_only_its_first_start_and_keeps_its_own_command_folder( "2026-10-03 12:00:00 {subject} started: {relative}, on claude · opus · high\n2026-10-03 12:00:00 {subject} ended: ready\n" ) ); - assert_eq!( - memory.0.lock().unwrap().files.len(), - if matches!(work, Work::Run(_)) { 3 } else { 2 } - ); + assert_eq!(memory.0.lock().unwrap().files.len(), 3); } } diff --git a/src/pass.rs b/src/pass.rs index 7a5623cf2..31dfc45a8 100644 --- a/src/pass.rs +++ b/src/pass.rs @@ -284,7 +284,7 @@ impl Outside for LaunchAndGitHub<'_> { /// The run's asks are the Architect plan's or the Ready issue's, from /// the command's flags and the User config, on the checked Harness. fn dispatch(&mut self, dispatch: Dispatch) -> Ended { - let (issue, asks, base) = match dispatch { + let (issue, asks, base, is_spec) = match dispatch { Dispatch::SecurityFix { issue, is_spec, @@ -298,17 +298,27 @@ impl Outside for LaunchAndGitHub<'_> { issue, Asks::of_ready_issue(issue, is_spec, self.flags, self.config), base, + is_spec, ), Dispatch::ArchitectPlan { plan, base } => ( plan, Asks::of_architect_plan(plan, self.flags, self.config), base, + false, // The Run's launch checks discover an Architect plan's Tickets. ), }; let mut asks = Asks { harness: self.harness.clone(), ..asks }; + logs::dispatching( + if is_spec { + Work::SpecRun(issue) + } else { + Work::Run(issue) + }, + self.harness, + ); let ended = run::run_to_end(issue, &mut asks, StartedBy::Dispatch { base }); logs::dispatch_ended(&ended); ended diff --git a/tests/architect_run.rs b/tests/architect_run.rs index 90317ea93..c2d7b4356 100644 --- a/tests/architect_run.rs +++ b/tests/architect_run.rs @@ -1841,13 +1841,7 @@ fn status_nests_the_architect_plans_run_or_spec_run_under_its_pass() { text.stdout ); let folder = "home/.thirdshift/logs/acme/widgets/commands/architect"; - let names = scenario.entries(folder); - let record = names - .iter() - .find(|name| name.ends_with(".command.json")) - .unwrap(); - let record: serde_json::Value = - serde_json::from_slice(&fs::read(scenario.path(folder).join(record)).unwrap()).unwrap(); + let record = scenario.command_record(folder); assert_eq!(record["outcome"], "ready for review"); assert_eq!(record["dispatched"][0]["outcome"], "ready for review"); assert_eq!(record["dispatched"][0]["issue_branch"], "issue-8"); @@ -1858,6 +1852,31 @@ fn status_nests_the_architect_plans_run_or_spec_run_under_its_pass() { } } +#[test] +fn a_dispatch_that_fails_its_launch_checks_still_has_a_record() { + let scenario = scenario(); + scenario.agent_does_in_session( + 1, + &format!("{AGENT_PUBLISHES_A_TICKET}\ngit config user.name ''\n"), + ); + let ended = scenario.run(&["architect"]); + assert_eq!(ended.code, Some(1), "{}", ended.stderr); + assert!( + ended.stderr.contains("git user.name is not set"), + "{}", + ended.stderr + ); + let folder = "home/.thirdshift/logs/acme/widgets/commands/architect"; + let record = scenario.command_record(folder); + assert_eq!( + record["dispatched"].as_array().unwrap().len(), + 1, + "{record}" + ); + assert_eq!(record["dispatched"][0]["issue"]["number"], 8); + assert_eq!(record["dispatched"][0]["outcome"], "failed"); +} + #[test] fn a_second_architect_run_on_the_repository_is_skipped_while_the_first_is_mid_review() { let scenario = scenario(); diff --git a/tests/security_run.rs b/tests/security_run.rs index dacaa2eb7..b3fa110bb 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -464,13 +464,7 @@ fn status_tracks_security_stages_and_nests_the_dispatched_fix() { ); } let folder = "home/.thirdshift/logs/acme/widgets/commands/secure"; - let names = scenario.entries(folder); - let record = names - .iter() - .find(|name| name.ends_with(".command.json")) - .unwrap(); - let record: Value = - serde_json::from_slice(&fs::read(scenario.path(folder).join(record)).unwrap()).unwrap(); + let record = scenario.command_record(folder); let stages: Vec<_> = record["stages"] .as_array() .unwrap() diff --git a/tests/status.rs b/tests/status.rs index 6eb73d691..438bc0424 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -57,16 +57,6 @@ fn status_shows_each_idle_pass_until_its_own_kind_starts_work() { assert!(!idle.iter().any(|pass| pass["kind"] == "Pickup run")); } -fn retained(scenario: &Scenario) -> Value { - let files: Vec<_> = scenario - .entries(LOGS) - .into_iter() - .filter(|name| name.ends_with(".command.json")) - .collect(); - assert_eq!(files.len(), 1, "{files:?}"); - serde_json::from_str(&fs::read_to_string(scenario.path(LOGS).join(&files[0])).unwrap()).unwrap() -} - #[test] fn status_lists_a_live_run_and_keeps_its_outcome_after_it_ends() { let scenario = Scenario::new(); @@ -119,7 +109,7 @@ gh pr create --base main --head issue-7 --title 'Make widgets' --body 'Closes #7 let result = scenario.run(&["status", "--json"]); let status: Value = serde_json::from_str(&result.stdout).unwrap(); assert_eq!(status["commands"], serde_json::json!([])); - let record = retained(&scenario); + let record = scenario.command_record(LOGS); assert_eq!(record["outcome"], "ready for review"); assert_eq!(record["cause"], Value::Null); assert_eq!(record["issue_branch"], "issue-7"); @@ -135,7 +125,7 @@ fn a_merge_run_retains_delivery_stages_and_their_start_times() { scenario.agent_does(OPENS_PR); let ended = scenario.run(&["merge", &scenario.issue_url(7)]); assert_eq!(ended.code, Some(0), "{}", ended.stderr); - let record = retained(&scenario); + let record = scenario.command_record(LOGS); let stages = record["stages"].as_array().unwrap(); let names: Vec<_> = stages .iter() @@ -170,7 +160,7 @@ fn a_failed_run_retains_its_cause_and_does_not_stay_live() { scenario.agent_does("exit 3\n"); let ended = scenario.run(&[&scenario.issue_url(7)]); assert_eq!(ended.code, Some(1), "{}", ended.stderr); - let record = retained(&scenario); + let record = scenario.command_record(LOGS); assert_eq!(record["outcome"], "failed"); assert!( record["cause"] @@ -203,7 +193,7 @@ fn failed_acquisition_does_not_claim_a_branch_was_left() { .unwrap(); assert!(!local.status.success()); assert_eq!( - retained(&scenario)["issue_branch"], + scenario.command_record(LOGS)["issue_branch"], Value::Null, "{}", ended.stderr @@ -220,7 +210,7 @@ fn refused_remote_deletion_keeps_branch_in_record() { assert_eq!(ended.code, Some(0), "{}", ended.stderr); assert!(scenario.origin_log("issue-7").is_some(), "{}", ended.stderr); assert_eq!( - retained(&scenario)["issue_branch"], + scenario.command_record(LOGS)["issue_branch"], "issue-7", "{}", ended.stderr @@ -237,7 +227,7 @@ fn failed_continuation_acquisition_keeps_existing_remote_branch() { assert_eq!(ended.code, Some(1), "{}", ended.stderr); assert!(scenario.origin_log("issue-7").is_some(), "{}", ended.stderr); assert_eq!( - retained(&scenario)["issue_branch"], + scenario.command_record(LOGS)["issue_branch"], "issue-7", "{}", ended.stderr @@ -265,7 +255,7 @@ fn a_killed_run_shows_as_died_at_its_last_stage() { assert_eq!(status["commands"][0]["state"], "died"); assert_eq!(status["commands"][0]["stage"]["name"], "implement"); assert!(text.stdout.contains("died at implement"), "{}", text.stdout); - assert_eq!(retained(&scenario)["outcome"], Value::Null); + assert_eq!(scenario.command_record(LOGS)["outcome"], Value::Null); } #[test] @@ -274,7 +264,7 @@ fn status_reads_all_repositories_under_configured_logs_and_ignores_unknown_stage scenario.agent_does(OPENS_PR); let ended = scenario.run(&[&scenario.issue_url(7)]); assert_eq!(ended.code, Some(0), "{}", ended.stderr); - let mut record = retained(&scenario); + let mut record = scenario.command_record(LOGS); record["outcome"] = Value::Null; record["process_start"] = "different process start".into(); record["stages"] = serde_json::json!([ @@ -317,7 +307,7 @@ fn repair_resume_and_security_review_keep_their_own_session_logs() { scenario.agent_does_in_session(4, "echo fix > fix.txt\ngit add fix.txt\ngit commit -qm 'Fix CI'\ngh fake checks \"$(git rev-parse HEAD)\" '[{\"name\":\"test\",\"conclusion\":\"success\"}]'\n"); let ended = scenario.run(&[&scenario.issue_url(7), "security-review"]); assert_eq!(ended.code, Some(0), "{}", ended.stderr); - let record = retained(&scenario); + let record = scenario.command_record(LOGS); let stages = record["stages"].as_array().unwrap(); for (name, log) in [ ("Resume", "-implement-resume.jsonl"), diff --git a/tests/support/mod.rs b/tests/support/mod.rs index ff1447c79..6b06b3a14 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1315,6 +1315,17 @@ test -f {root}/{COPY_REPLACED} .collect() } + /// The single retained Command record in a command folder. + pub fn command_record(&self, folder: &str) -> Value { + let records: Vec<_> = self + .entries(folder) + .into_iter() + .filter(|name| name.ends_with(".command.json")) + .collect(); + assert_eq!(records.len(), 1, "{records:?}"); + serde_json::from_slice(&fs::read(self.path(folder).join(&records[0])).unwrap()).unwrap() + } + /// Point the launch clone's origin at `url`, another spelling of the /// GitHub URL, which git also redirects to the bare repo. pub fn set_origin_url(&self, url: &str) { From dd552518df771e691b61204fe6da81cb6127d3f0 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sun, 18 Oct 2026 11:56:19 -0400 Subject: [PATCH 3/3] Exclude Command records from command-log file assertions --- tests/command_logs.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/command_logs.rs b/tests/command_logs.rs index 25e33864f..b15d01d3e 100644 --- a/tests/command_logs.rs +++ b/tests/command_logs.rs @@ -48,14 +48,15 @@ fn run_in_zone(scenario: &Scenario, args: &[&str], env: &[(&str, &str)]) -> RunR scenario.run_with_env(args, &[&[ZONE], env].concat()) } -/// The names of the files in `folder` under the logs, sorted, none if it is -/// missing. +/// Log file names in `folder`, sorted, excluding retained Command records; +/// none if the folder is missing. fn logs_in(scenario: &Scenario, folder: &str) -> Vec { let Ok(entries) = fs::read_dir(scenario.path(LOGS).join(folder)) else { return Vec::new(); }; let mut names: Vec = entries .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .filter(|name| !name.ends_with(".command.json")) .collect(); names.sort(); names