Try again picks up where a build stopped instead of starting over

A run with no message was read as a first build, so Try again studied the
original again and gave the builder the first brief, even with its session
saved. A run now carries on when the builder has already worked, and only
runs the check when the builder had finished (awaiting_check in craft.json).

A check that returns an error couldn't look at the build at all, so it gets a
second go, and then the run says the build is finished but couldn't be checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-31 20:55:46 -04:00
parent 8c0d7b0a38
commit ddc7beb861
7 changed files with 275 additions and 27 deletions
+59 -27
View File
@@ -125,7 +125,8 @@ pub struct RunOptions {
pub shown: Option<String>,
/// Pick up a build whose last run stopped part-way (paused, the app closed, the computer
/// stopped): Study isn't repeated when it was done, and an agent with a saved session is
/// told to carry on rather than given the brief again.
/// told to carry on rather than given the brief again. A run with no message on a project
/// whose builder has already worked does the same, so "Try again" never starts over.
pub continuing: bool,
/// Refinement rounds after the first build (each fixes what the last check found).
pub max_rounds: u32,
@@ -579,7 +580,8 @@ impl Worker {
self.self_check = Some((kind.clone(), opts.good_enough));
let kind = kind.as_ref();
self.editor = self.find_editor(&ws.manifest.stack)?;
let first = (opts.message.is_none() && !opts.continuing) || !ws.capture_dir().exists();
let carrying_on = opts.continuing || (opts.message.is_none() && ws.builder_started());
let first = (opts.message.is_none() && !carrying_on) || !ws.capture_dir().exists();
if first {
self.study(ws, kind)?;
if let Some((name, ex)) = extractor {
@@ -610,7 +612,7 @@ impl Worker {
// from the brief with the request added.
Some(m) if !first && resumable => follow_up(m, kind) + &now(),
// Carrying on after a stop: the same, told to finish what it was doing.
None if opts.continuing && !first && has_history => continue_prompt(kind) + &now(),
None if carrying_on && !first && has_history => continue_prompt(kind) + &now(),
_ => {
let b = brief(kind, &cx, opts.message.as_deref());
if api_loop { format!("{API_BUILDER}\n\n{b}") } else { b }
@@ -625,35 +627,47 @@ impl Worker {
// Deep mode on a program: decompiling is required, so a build turn without any gets one reminder.
let must_decompile = !self.engine.session().clean_room() && matches!(ws.manifest.target, TargetRef::Local { .. });
let mut reminded = false;
// The builder finished and only the check didn't: the build is there to check.
let mut only_check = carrying_on && !first && opts.message.is_none() && ws.manifest.awaiting_check;
loop {
self.step(ws, Step::Build, Status::Building, if round == 0 { "Building the first version…" } else { "Improving the build…" })?;
let reply = self.turn(ws, agent.as_mut(), &prompt)?;
if !reply.trim().is_empty() {
ws.manifest.last_reply = reply.trim().to_string();
}
// The stack is recorded as the one the build uses, once it shows: left to the builder,
// or another than the default because the request asked for it. A game engine stays
// (it says the project is a game, and its editor is found for it).
if game::Builder::from_stack(&ws.manifest.stack).is_none()
&& let Some(s) = stacks::used_in(&ws.app_dir())
{
ws.manifest.stack = s.name.clone();
}
ws.save()?;
// What the person added while the agent worked goes in before anything else.
if let Some(p) = self.added(ws) {
prompt = p;
continue;
}
if must_decompile && !self.decompiled && !reminded {
reminded = true;
prompt = DECOMPILE_FIRST.to_string();
continue;
if !std::mem::take(&mut only_check) {
let doing = match (round, carrying_on) {
(0, false) => "Building the first version…",
(0, true) => "Carrying on with the build…",
_ => "Improving the build…",
};
self.step(ws, Step::Build, Status::Building, doing)?;
ws.manifest.awaiting_check = false;
let reply = self.turn(ws, agent.as_mut(), &prompt)?;
if !reply.trim().is_empty() {
ws.manifest.last_reply = reply.trim().to_string();
}
// The stack is recorded as the one the build uses, once it shows: left to the builder,
// or another than the default because the request asked for it. A game engine stays
// (it says the project is a game, and its editor is found for it).
if game::Builder::from_stack(&ws.manifest.stack).is_none()
&& let Some(s) = stacks::used_in(&ws.app_dir())
{
ws.manifest.stack = s.name.clone();
}
ws.manifest.awaiting_check = true;
ws.save()?;
// What the person added while the agent worked goes in before anything else.
if let Some(p) = self.added(ws) {
prompt = p;
continue;
}
if must_decompile && !self.decompiled && !reminded {
reminded = true;
prompt = DECOMPILE_FIRST.to_string();
continue;
}
}
round += 1;
self.step(ws, Step::Check, Status::Checking, "Checking the build against the original…")?;
let checks = self.run_checks(ws, kind, round)?;
let checks = self.checks_with_a_retry(ws, kind, round)?;
ws.manifest.checks.extend(checks.iter().cloned());
ws.manifest.awaiting_check = false;
ws.save()?;
for c in &checks {
self.emit(RunEvent::Checked(c.clone()));
@@ -723,6 +737,24 @@ impl Worker {
}
}
/// The round's checks. A check that fails says what's wrong with the build; one that returns
/// an error couldn't look at all (a browser that went away), which says nothing about the
/// build, so it gets a second go before the run stops, and the run says what is really wrong.
fn checks_with_a_retry(&self, ws: &Workspace, kind: &dyn kinds::TargetKind, round: u32) -> Result<Vec<CheckResult>, ToolError> {
let e = match self.run_checks(ws, kind, round) {
Err(e) if e.code != ErrorCode::Cancelled => e,
done => return done,
};
self.emit(RunEvent::Agent(AgentEvent::Status { text: format!("The check couldn't finish ({}). Trying it once more…", e.message) }));
self.run_checks(ws, kind, round).map_err(|e| {
if e.code == ErrorCode::Cancelled {
return e;
}
ToolError::new(e.code, format!("The build is finished, but it couldn't be checked: {}", e.message))
.with_remediation("Press Try again to check it again. Nothing needs building again.")
})
}
fn turn(&mut self, ws: &mut Workspace, agent: &mut dyn Conversation, prompt: &str) -> Result<String, ToolError> {
let mut prompt = prompt.to_string();
let mut tries = 0u64;
+184
View File
@@ -751,6 +751,190 @@ fn clean_room_studies_nothing_inside_and_deep_mode_looks() {
}
/// A Worker for checks, on a session with Deep mode on or off.
/// A kind whose check can't run while `app/check-fails` still counts down, and a stand-in coding
/// agent that notes each prompt it gets in `prompts.txt` beside the project.
struct FlakyKind;
struct FlakyCheck;
impl Check for FlakyCheck {
fn id(&self) -> &'static str {
"flaky"
}
fn run(&self, cx: &mut CheckCx) -> Result<CheckResult, ToolError> {
let file = cx.ws.app_dir().join("check-fails");
let left: u32 = std::fs::read_to_string(&file).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if left > 0 {
std::fs::write(&file, (left - 1).to_string()).unwrap();
return Err(ToolError::new(ErrorCode::EngineMissing, "the WebSocket is closed"));
}
Ok(CheckResult::new(self.id(), cx.round, true, "Fine."))
}
}
impl TargetKind for FlakyKind {
fn id(&self) -> &'static str {
"flaky"
}
fn words(&self) -> KindWords {
KindWords { button: "Open", icon: "box", ready: "Ready", change_hint: "Ask" }
}
fn detect(&self, _cx: &DetectCx) -> Option<u32> {
Some(1000)
}
fn default_stack(&self, _cx: &DetectCx) -> &'static str {
"Anything"
}
fn goal(&self) -> &'static str {
"a thing"
}
fn checks(&self, _ws: &Workspace) -> Vec<Box<dyn Check>> {
vec![Box::new(FlakyCheck)]
}
}
#[cfg(unix)]
mod picking_up_a_build {
use super::*;
use std::os::unix::fs::PermissionsExt;
struct Bench {
_dir: tempfile::TempDir,
hub: AiHub,
projects: PathBuf,
prompts: PathBuf,
}
fn bench() -> Bench {
let dir = tempfile::tempdir().unwrap();
let prompts = dir.path().join("prompts.txt");
let agent = dir.path().join("agent.sh");
std::fs::write(&agent, format!("#!/bin/sh\nfor last; do :; done\nprintf '%s\\n===\\n' \"$last\" >> '{}'\necho Done.\n", prompts.display())).unwrap();
std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap();
let engine = Arc::new(Engine::new(craft_anything_engine::testing::session()));
let hub = AiHub::with_stores(engine, dir.path().join("settings.toml"), crate::KeyStore::file_only(dir.path().join("keys.toml")));
let mut cfg = hub.config();
cfg.upsert(crate::BackendEntry {
config: crate::BackendConfig {
name: "Stand-in".into(),
kind: BackendKind::AgentCli,
model: "stand-in".into(),
base_url: None,
ssh_host: None,
enabled: true,
},
options: crate::BackendOptions { command: Some(agent.display().to_string()), ..Default::default() },
});
hub.engine.session().settings.ai = cfg.to_table().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(&projects).unwrap();
Bench { _dir: dir, hub, projects, prompts }
}
fn try_it(b: &Bench, ws: &Workspace) -> (bool, Vec<feed::FeedItem>) {
let mut kinds = KindRegistry::built_in();
kinds.register(Arc::new(FlakyKind));
let run =
RebuildRun::start(&b.hub, Workspace::open(&ws.root).unwrap(), RunOptions { kinds, backend: Some("Stand-in".into()), ..RunOptions::default() })
.unwrap();
let began = Instant::now();
let mut ok = None;
while ok.is_none() {
assert!(began.elapsed() < Duration::from_secs(30), "the run never finished");
match run.rx.recv_timeout(Duration::from_secs(30)) {
Ok(RunEvent::Finished { ok: o, .. }) => ok = Some(o),
Ok(_) => {}
Err(e) => panic!("the run went away: {e}"),
}
}
(ok.unwrap(), feed::load(&Workspace::open(&ws.root).unwrap()))
}
fn prompts_given(b: &Bench) -> usize {
std::fs::read_to_string(&b.prompts).unwrap_or_default().matches("===").count()
}
fn studied(feed: &[feed::FeedItem]) -> bool {
feed.iter().any(|i| matches!(i, feed::FeedItem::Step { step: Step::Study, .. }))
}
/// A project whose builder has worked: its session and its study are there.
fn worked_on(b: &Bench) -> Workspace {
let mut ws = Workspace::create(&b.projects, "x", "A thing", TargetRef::None).unwrap();
ws.manifest.agent_session = Some("session-1".into());
ws.manifest.stack = "Anything".into();
ws.save().unwrap();
std::fs::create_dir_all(ws.capture_dir()).unwrap();
std::fs::create_dir_all(ws.app_dir()).unwrap();
ws
}
#[test]
fn try_again_after_a_check_that_could_not_run_only_checks() {
let b = bench();
let mut ws = worked_on(&b);
ws.manifest.awaiting_check = true;
ws.save().unwrap();
let (ok, feed) = try_it(&b, &ws);
assert!(ok, "{feed:?}");
assert_eq!(prompts_given(&b), 0, "the builder had finished: it isn't asked again");
assert!(!studied(&feed), "the original isn't studied again");
let ws = Workspace::open(&ws.root).unwrap();
assert!(!ws.manifest.awaiting_check);
assert_eq!(ws.manifest.checks.len(), 1);
}
#[test]
fn try_again_after_the_builder_stopped_carries_on_instead_of_starting_over() {
let b = bench();
let ws = worked_on(&b);
let (ok, feed) = try_it(&b, &ws);
assert!(ok, "{feed:?}");
assert!(!studied(&feed), "the original isn't studied again");
let told = std::fs::read_to_string(&b.prompts).unwrap();
assert!(told.contains("Pick up where you left off"), "{told}");
assert!(!told.contains("Their request"), "not the first-build brief again: {told}");
}
#[test]
fn a_new_project_still_starts_from_the_top() {
let b = bench();
let ws = Workspace::create(&b.projects, "x", "A thing", TargetRef::None).unwrap();
let (ok, feed) = try_it(&b, &ws);
assert!(ok, "{feed:?}");
let told = std::fs::read_to_string(&b.prompts).unwrap();
assert!(!told.contains("Pick up where you left off"), "{told}");
}
#[test]
fn a_check_that_could_not_run_gets_a_second_go_and_the_build_is_not_blamed() {
let b = bench();
let mut ws = worked_on(&b);
std::fs::write(ws.app_dir().join("check-fails"), "1").unwrap();
ws.manifest.awaiting_check = true;
ws.save().unwrap();
let (ok, _) = try_it(&b, &ws);
assert!(ok, "one lost browser doesn't fail a finished build");
// It keeps failing: the run stops, says the build is fine, and Try again only checks.
let mut ws = worked_on(&b);
ws.manifest.name = "y".into();
ws.manifest.awaiting_check = false;
ws.save().unwrap();
std::fs::write(ws.app_dir().join("check-fails"), "2").unwrap();
let (ok, feed) = try_it(&b, &ws);
assert!(!ok);
assert!(
feed.iter().any(|i| matches!(i, feed::FeedItem::Error { message, .. } if message.contains("The build is finished, but it couldn't be checked"))),
"{feed:?}"
);
let asked = prompts_given(&b);
assert!(Workspace::open(&ws.root).unwrap().manifest.awaiting_check, "the builder's turn is on record");
let (ok, _) = try_it(&b, &ws);
assert!(ok);
assert_eq!(prompts_given(&b), asked, "and it isn't asked to build again");
}
}
fn check_worker(ws: &Workspace, deep: bool) -> Worker {
let mut s = craft_anything_engine::testing::session();
s.settings.deep_mode = deep;
+11
View File
@@ -390,6 +390,10 @@ pub struct Manifest {
/// (`Settings::questions`). The person's, like the budget.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub questions: Option<Questions>,
/// The builder finished its turn and the run's checks haven't reported on it yet, so a run
/// that picks this up only needs to check: nothing is left to build.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub awaiting_check: bool,
}
impl Manifest {
@@ -415,6 +419,7 @@ impl Manifest {
start_files: Vec::new(),
builder: None,
questions: None,
awaiting_check: false,
}
}
@@ -817,6 +822,12 @@ impl Workspace {
self.root.join("inbox")
}
/// The API builder's conversation, kept so a follow-up continues it (`ai::context`).
/// A builder has worked on this project and its conversation was kept, so a later run carries
/// it on instead of starting over.
pub fn builder_started(&self) -> bool {
self.manifest.agent_session.is_some() || self.transcript_path().is_file()
}
pub fn transcript_path(&self) -> PathBuf {
self.root.join("transcript.json")
}
+1
View File
@@ -237,6 +237,7 @@ fn workspace(slug: &str, name: &str, request: &str, target: TargetRef, status: S
start_files: Vec::new(),
builder: None,
questions: None,
awaiting_check: false,
},
}
}
+10
View File
@@ -777,6 +777,16 @@ Gaps found while using the app for real projects.
that couldn't start, panics
- [ ] Sometimes starting the app shows nothing: find why from `desktop.log` (a GOG Galaxy Vulkan
overlay layer loads into the app; try DX12 first on Windows if that's it)
- [x] Try again picks up where a run stopped instead of starting over: a project whose builder
has worked isn't studied again or given the first brief; when the builder had finished and
only the check didn't (`awaiting_check` in craft.json), only the check runs. A check that
returns an error gets a second go, and then the run says the build is finished but couldn't
be checked
- [ ] Find why a site capture lost its browser ("the WebSocket is closed", 419 ms into
`web_site_capture` for a second page, seen once; the same capture passed 41 s earlier)
- [ ] Two engine tests fail on a Mac, on `main` too: `real_binary_end_to_end_if_present` expects
`/bin/ls` to be Linux, and `auto_mode_keeps_going_but_never_does_the_dangerous_things` is
refused `rm -rf` in a temp folder under `/var/folders`
### R2.10 Pinned builds
Owner: maintainers · Status: in progress
+6
View File
@@ -314,6 +314,12 @@ computer. Say what ran and what it printed, write the rest to a log the person c
agent run does: `crate::failure_log`), and never name a next step you don't spell out. Exit 255
from ssh is ssh's own failure: don't blame the remote program. Test a message by causing the failure.
**A retry that took "no message" for "brand new".** A run with no message and no `continuing`
flag was read as a first build, and Try again starts exactly that run. A browser that died during
the final check failed a finished build; Try again then studied the original again and gave the
builder the first brief. A run decides what's left from what the project records, not from which
button started it, and a check that couldn't look says nothing about the build.
**A filter written for one kind of text, run on another.** The stand-in's secret scan was written for
API answers, then also ran on recorded scripts: `this.token=t` became `[dropped]` and the scripts
broke, and "Basic Membership" lost a word. It also tested whole tokens, so a secret inside a URL or
+4
View File
@@ -30,6 +30,10 @@
"format": "double",
"default": 5.0
},
"awaiting_check": {
"description": "The builder finished its turn and the run's checks haven't reported on it yet, so a run\nthat picks this up only needs to check: nothing is left to build.",
"type": "boolean"
},
"builder": {
"description": "The AI the person chose to build this project (a backend's name); none: the default\nbuilder. The person's, like the budget.",
"type": [