Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <stage>`. 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 <stage>`. 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:

Expand All @@ -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,
Expand All @@ -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

Expand Down
37 changes: 33 additions & 4 deletions src/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -172,14 +171,25 @@ 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
} else if kind == "security-review" {
Stage::SecurityReview
} else if kind == "implement" {
Stage::Implement
} else if kind == "spec-review" {
Stage::SpecReview
} else {
return;
};
Expand All @@ -196,6 +206,16 @@ 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));
}

/// 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() {
Expand Down Expand Up @@ -255,6 +275,15 @@ fn root_under(logs_dir: &Path, repo: &Repo) -> PathBuf {
}

impl Work<'_> {
fn initial_stage(self) -> Option<Stage> {
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 {
Expand Down
79 changes: 70 additions & 9 deletions src/logs/command_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandRecord>,
}

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<Issue>,
pub hostname: String,
pub process_id: u32,
pub process_start: String,
Expand Down Expand Up @@ -54,6 +74,12 @@ pub enum StageName {
Repair,
SelfMerge,
Cleanup,
ArchitectureReview,
Tickets,
SpecReview,
SecurityAudit,
SecurityReproduction,
SecurityFixPublishing,
}

impl StageName {
Expand All @@ -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",
}
}

Expand All @@ -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)
Expand All @@ -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<FixedOffset>) -> Self {
Self {
version: 1,
kind: "Run".into(),
repository: issue.repo_slug(),
issue: Issue {
pub fn new(work: Work<'_>, stamp: &str, harness: &Choice, now: DateTime<FixedOffset>) -> 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(),
Expand All @@ -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);
}
}
79 changes: 57 additions & 22 deletions src/logs/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,7 +31,7 @@ pub(super) struct Record<O: Outside> {
command_log: Log<O::CommandLog>,
activity_started: Option<Started>,
activity_warned: bool,
command_record: Option<(PathBuf, CommandRecord)>,
command_record: Option<(PathBuf, CommandTree)>,
record_warned: bool,
}

Expand Down Expand Up @@ -122,17 +122,25 @@ impl<O: Outside> Record<O> {
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 Some((_, tree)) = &mut self.command_record
&& let Some(dispatched) = tree.dispatched.last_mut()
{
// 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;
}
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
Expand All @@ -150,8 +158,18 @@ impl<O: Outside> Record<O> {
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() {
Expand Down Expand Up @@ -193,24 +211,27 @@ impl<O: Outside> Record<O> {
}

pub(super) fn issue_title(&mut self, title: Option<String>) {
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());
}
Expand All @@ -227,14 +248,28 @@ impl<O: Outside> Record<O> {
}

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();
}
}

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();
}
}
Expand Down
Loading
Loading