Files
Brandon Thomas 6a120011a1 Explain failed agent runs, keep a log to open, and stop offering git servers as computers (#3)
* Explain failed agent runs, keep a log to open, and stop offering git servers as computers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix a lock-up: the build's agent setup took the session lock twice in one statement

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:44 -04:00

197 lines
8.4 KiB
Rust

//! A project's conversation, as data: what the person asked, what the builder did and said, its
//! checklist, questions and checks. It's kept in the project folder (`feed.json`); a rebuild run
//! appends to it as it goes (`ai::rebuild::feed`), and every client reads the same file.
use crate::inbox::{Inbox, Request};
use crate::workspace::{CheckResult, Status, Step, Workspace};
use craft_anything_evidence::{ErrorCode, ToolError};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use std::path::Path;
/// Where the conversation is kept in the project folder.
pub const FEED_FILE: &str = "feed.json";
/// The format written today (2 added `Paused`); files without a version are the earlier bare list.
pub const FEED_VERSION: u32 = 2;
/// One step of the builder's own checklist (from its TodoWrite list).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanStep {
pub text: String,
/// What it says while doing this step ("Drawing the map").
pub doing: String,
pub status: PlanStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlanStatus {
Todo,
Doing,
Done,
}
/// One entry in a project's activity feed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FeedItem {
/// The builder's checklist, kept up to date in place (shown pinned above the prompt box).
Plan(Vec<PlanStep>),
User(String),
Step {
step: Step,
text: String,
},
/// The agent's words (streamed).
Agent {
text: String,
streaming: bool,
},
/// A run of tool calls, collapsed in Simple mode: (tool, finished ok?).
Work {
tools: Vec<(String, Option<bool>)>,
notes: Vec<String>,
},
/// A question or approval; `answer` is the person's reply once given.
Ask {
request: Request,
answer: Option<String>,
},
Check(CheckResult),
Error {
message: String,
remediation: Option<String>,
/// A log with everything known about the failure, to open and inspect.
#[serde(default, skip_serializing_if = "Option::is_none")]
log: Option<std::path::PathBuf>,
},
/// The person paused the run (not a failure); a message or Continue carries on.
Paused,
}
/// Mark a question or approval answered, with the words shown for the answer.
pub fn answered(feed: &mut [FeedItem], id: &str, shown: &str) {
for item in feed {
if let FeedItem::Ask { request, answer: a @ None } = item
&& request.id == id
{
*a = Some(shown.to_string());
}
}
}
/// The saved file: `{version, items}`. Earlier files were the bare list.
#[derive(Serialize, Deserialize)]
struct Saved {
version: u32,
items: Vec<FeedItem>,
}
/// The feed for a project: what was saved, else rebuilt from its manifest. Questions still
/// waiting in its inbox are always there.
pub fn load(ws: &Workspace) -> Vec<FeedItem> {
let saved = std::fs::read(ws.root.join(FEED_FILE)).ok().and_then(|b| {
serde_json::from_slice::<Saved>(&b)
.ok()
.filter(|s| s.version <= FEED_VERSION)
.map(|s| s.items)
.or_else(|| serde_json::from_slice::<Vec<FeedItem>>(&b).ok())
});
let Some(mut feed) = saved else { return history(ws) };
// Nothing is streaming any more after a restart.
for item in &mut feed {
if let FeedItem::Agent { streaming, .. } = item {
*streaming = false;
}
}
let shown: HashSet<String> = feed.iter().filter_map(|i| if let FeedItem::Ask { request, .. } = i { Some(request.id.clone()) } else { None }).collect();
feed.extend(Inbox::new(ws.inbox_dir()).pending().into_iter().filter(|r| !shown.contains(&r.id)).map(|request| FeedItem::Ask { request, answer: None }));
feed
}
/// A feed rebuilt from what a project saved: the request, the checks, the agent's last words,
/// and anything still waiting for an answer.
pub fn history(ws: &Workspace) -> Vec<FeedItem> {
let m = &ws.manifest;
let mut feed = vec![FeedItem::User(m.request.clone())];
feed.extend(m.checks.iter().cloned().map(FeedItem::Check));
if !m.last_reply.is_empty() {
feed.push(FeedItem::Agent { text: m.last_reply.clone(), streaming: false });
}
if matches!(m.status, Status::NeedsYou) && !m.note.is_empty() {
feed.push(FeedItem::Error { message: m.note.clone(), remediation: None, log: None });
}
feed.extend(Inbox::new(ws.inbox_dir()).pending().into_iter().map(|request| FeedItem::Ask { request, answer: None }));
feed
}
/// Write the feed into the project folder (only one that exists; never creates one). A feed
/// saved by a newer Craft Anything is never written over (docs/file-formats.md).
pub fn save(root: &Path, feed: &[FeedItem]) -> Result<(), ToolError> {
if !root.is_dir() {
return Ok(());
}
let newer = std::fs::read(root.join(FEED_FILE))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("version").and_then(Value::as_u64))
.is_some_and(|v| v > u64::from(FEED_VERSION));
if newer {
return Err(ToolError::new(ErrorCode::NotSupported, "This project's conversation was saved by a newer Craft Anything, so it isn't changed.")
.with_remediation("Update Craft Anything to keep its conversation up to date."));
}
let json = serde_json::to_vec(&Saved { version: FEED_VERSION, items: feed.to_vec() }).map_err(|e| ToolError::internal(e.to_string()))?;
crate::project::write_atomic(&root.join(FEED_FILE), &json)
}
/// Record an answer given while no run is going (straight into the saved feed).
pub fn record_answer(ws: &Workspace, id: &str, shown: &str) -> Result<(), ToolError> {
let mut feed = load(ws);
answered(&mut feed, id, shown);
save(&ws.root, &feed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inbox::RequestKind;
use crate::workspace::TargetRef;
#[test]
fn history_rebuilds_from_the_manifest() {
let d = tempfile::tempdir().unwrap();
let mut ws = Workspace::create(d.path(), "x", "Clone it", TargetRef::None).unwrap();
ws.manifest.checks.push(CheckResult { similarity: Some(70.0), ..CheckResult::new(CheckResult::WEB_VISUAL, 1, true, "") });
ws.manifest.last_reply = "Built a page.".into();
ws.save().unwrap();
Inbox::new(ws.inbox_dir()).post(RequestKind::Question, "Blue or red?", "", vec![], true, None).unwrap();
let f = history(&ws);
assert!(matches!(&f[0], FeedItem::User(r) if r == "Clone it"));
assert!(matches!(f.last(), Some(FeedItem::Ask { answer: None, .. })), "pending questions come back");
assert_eq!(f.len(), 4);
}
#[test]
fn older_files_load_answers_are_kept_and_newer_files_are_left_alone() {
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "x", "Clone it", TargetRef::None).unwrap();
save(&ws.root, &[FeedItem::User("Clone it".into()), FeedItem::Agent { text: "Done.".into(), streaming: true }]).unwrap();
assert!(matches!(load(&ws).as_slice(), [FeedItem::User(_), FeedItem::Agent { streaming: false, .. }]), "nothing streams after a restart");
let raw: Value = serde_json::from_slice(&std::fs::read(ws.root.join(FEED_FILE)).unwrap()).unwrap();
assert_eq!(raw["version"], FEED_VERSION);
// A feed saved before versioning (the bare list) still loads.
std::fs::write(ws.root.join(FEED_FILE), br#"[{"User":"Old request"}]"#).unwrap();
assert!(matches!(load(&ws).as_slice(), [FeedItem::User(r)] if r == "Old request"));
// Answers given with no run going are saved too.
Inbox::new(ws.inbox_dir()).post(RequestKind::Question, "Blue or red?", "", vec![], true, None).unwrap();
let id = load(&ws).iter().find_map(|i| if let FeedItem::Ask { request, .. } = i { Some(request.id.clone()) } else { None }).unwrap();
record_answer(&ws, &id, "Blue").unwrap();
assert!(std::fs::read_to_string(ws.root.join(FEED_FILE)).unwrap().contains("\"Blue\""));
// One from a newer Craft Anything is shown as far as we can, never written over.
let newer = br#"{"version":99,"items":[{"User":"From the future"}],"extra":1}"#;
std::fs::write(ws.root.join(FEED_FILE), newer).unwrap();
assert!(save(&ws.root, &[FeedItem::User("x".into())]).is_err());
assert_eq!(std::fs::read(ws.root.join(FEED_FILE)).unwrap(), newer);
}
}