From c36e37808e9d7d1d02046f531c8684edb34ca8f2 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Mon, 14 Sep 2026 13:04:42 +0500 Subject: [PATCH] live-generate: stop waiting when the dev server dies A Cursor run reused a dev server another chat had started; that chat's terminal was reaped mid-session, so the page never reloaded into the overlay and --wait-for-browser ran out its 60 s budget before the agent found an error page and restarted the server by hand (about three minutes lost). The wait now watches the dev URL it knows (the one it opened, else the boot's, else the caller's hint) with a TCP connect every third tick; two misses in a row end it with dev_server_gone, whose instructions name the harness's way to start the dev script and rerun with --dev-url. generate.md lists the verdict; an integration test kills a stand-in server mid-wait and sees the verdict inside seconds. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 66 ++++++++++++++++++++++++++++++++ crates/live/src/dev_url.rs | 23 +++++++++++ crates/live/src/live_generate.rs | 53 +++++++++++++++++++++++++ docs/CLI-CONTRACT.md | 2 +- skill/reference/generate.md | 1 + 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 7ef4da99f..558f84d2d 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -845,6 +845,72 @@ fn live_generate_boot_and_open_run_the_lane_from_a_cold_project() { let _ = std::fs::remove_dir_all(&dir); } +/// The wait ends when the dev server dies (`--boot`'s detached helper +/// inherits a test's stdout pipe on Windows, so unix only, like the other +/// boot tests). +#[cfg(unix)] +#[test] +fn live_generate_stops_waiting_when_the_dev_server_dies() { + let dir = std::env::temp_dir().join(format!("impeccable-agent-target-devgone-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); + std::fs::write(dir.join("index.html"), "

t

").unwrap(); + std::fs::write(dir.join(".impeccable/live/config.json"), "{\"files\":[\"index.html\"],\"insertBefore\":\"\",\"commentSyntax\":\"html\"}").unwrap(); + // A stand-in dev server that serves the injected page until told to stop, + // then closes its port: the server a harness reaps mid-session. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let dev_port = listener.local_addr().unwrap().port(); + let dev_url = format!("http://127.0.0.1:{}/", dev_port); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_flag = stop.clone(); + let page_dir = dir.clone(); + std::thread::spawn(move || loop { + if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { + break; // the listener drops here and the port closes + } + match listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); + let mut buf = [0u8; 2048]; + let _ = std::io::Read::read(&mut stream, &mut buf); + let body = std::fs::read_to_string(page_dir.join("index.html")).unwrap_or_default(); + let res = format!("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + let _ = std::io::Write::write_all(&mut stream, res.as_bytes()); + } + Err(_) => std::thread::sleep(Duration::from_millis(30)), + } + }); + let started = std::time::Instant::now(); + let child = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(["live-generate", "--selector", "h1", "--action", "bolder", "--boot", "--dev-url", &dev_url, "--wait-for-browser", "30000"]) + .current_dir(&dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + .env("IMPECCABLE_DEV_URL_CANDIDATES", &dev_url) + .env("IMPECCABLE_AGENT_TARGET_TIMEOUT_MS", "400") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("cli"); + // The boot ran and the wait began; now the dev server goes away. + std::thread::sleep(Duration::from_millis(2500)); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + let out = child.wait_with_output().expect("cli output"); + let elapsed = started.elapsed(); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let last = stdout.trim().lines().last().unwrap_or(""); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).or_else(|_| serde_json::from_str(last)).unwrap_or_else(|e| panic!("{e}: {stdout}")); + assert_eq!(v["error"], serde_json::json!("dev_server_gone"), "{v}"); + assert_eq!(v["devUrl"], serde_json::json!(dev_url), "{v}"); + assert!(v["_instructions"].as_str().unwrap().contains("stopped answering"), "{v}"); + assert!(elapsed < Duration::from_secs(20), "the wait ran out its budget instead of noticing: {:?}", elapsed); + // Cleanup: stop the helper the boot started. + let info: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join(".impeccable/live/server.json")).unwrap()).unwrap(); + let _ = http(info["port"].as_u64().unwrap() as u16, "GET", &format!("/stop?token={}", info["token"].as_str().unwrap()), None); + std::thread::sleep(Duration::from_millis(500)); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn live_generate_asks_the_harness_to_open_the_page_instead_of_a_second_browser() { // Helper up, no page connected, nothing asked to open, nothing to wait diff --git a/crates/live/src/dev_url.rs b/crates/live/src/dev_url.rs index 4e1408dfb..2fdbe60e0 100644 --- a/crates/live/src/dev_url.rs +++ b/crates/live/src/dev_url.rs @@ -47,6 +47,29 @@ pub fn probe(candidates: &[String], token: &str) -> Option { hits.into_iter().flatten().next() } +/// Whether something accepts connections at the URL's host and port: the +/// liveness check `live-generate` runs while it waits for a page, cheap +/// enough for every few seconds and immune to a slow first render. +pub fn answers(url: &str) -> bool { + let Some(rest) = url.strip_prefix("http://") else { + return false; + }; + let Some(host_port) = rest.split('/').next() else { + return false; + }; + let (host, port) = match host_port.rsplit_once(':') { + Some((h, p)) => match p.parse::() { + Ok(port) => (h, port), + Err(_) => return false, + }, + None => (host_port, 80), + }; + let Some(addr) = (host, port).to_socket_addrs().ok().and_then(|mut a| a.next()) else { + return false; + }; + TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok() +} + /// A minimal HTTP/1.0 GET of `/`; returns the response body on any 2xx. fn fetch_root(url: &str) -> Option { let rest = url.strip_prefix("http://")?; diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index b5554894f..49bfe7fff 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -204,6 +204,11 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option format!( + "The dev server at {} stopped answering while this command waited for the page, so no page can load the overlay from it (a server another chat or session started dies with it). {}", + s("devUrl"), + start_dev_server_hint(&s("harness")) + ), "no_dev_server" => format!("No dev server is serving this app: none of the usual ports answered with the page carrying the helper's tag (pass --dev-url when you know where it runs). {}", start_dev_server_hint(&s("harness"))), "browser_needed" => format!("{}The helper is up and no page is connected yet. {} Then rerun this exact command with --wait-for-browser 60000.", open_ignored_note(result), open_in_harness_hint(&s("harness"), &s("devUrl"), self_cmd)), "browser_open_failed" => format!("The browser could not be launched ({}). Open {} yourself with your harness browser tool, or give the user the URL, then rerun this command with --wait-for-browser 120000.", s("detail"), s("url")), @@ -611,6 +616,20 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if wait_for_browser_ms > 0 { let deadline = Instant::now() + Duration::from_millis(wait_for_browser_ms); + // No page can load the overlay from a dead dev server, so the wait + // watches the one this command knows (the URL it opened, else the + // boot's or the caller's) and ends the moment it stops answering, + // instead of running out the budget on a page that will never + // reload. Two misses in a row, so a server mid-restart gets a grace. + let watched_dev_url: Option = opened + .as_ref() + .and_then(|o| o.get("url")) + .and_then(Value::as_str) + .map(String::from) + .or_else(|| resolve_dev_url(&boot).0); + let started = Instant::now(); + let mut ticks: u32 = 0; + let mut dev_misses: u32 = 0; loop { let Some(status) = crate::server::fetch_status(port, &token) else { return fail(io, server_died(&me, None, true)); @@ -618,6 +637,28 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) > 0 { break; } + if let Some(url) = &watched_dev_url { + ticks += 1; + if ticks % 3 == 0 { + if crate::dev_url::answers(url) { + dev_misses = 0; + } else { + dev_misses += 1; + } + if dev_misses >= 2 { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("dev_server_gone")); + v.insert("devUrl".into(), json!(url)); + v.insert("waitedMs".into(), json!(started.elapsed().as_millis() as u64)); + v.insert("harness".into(), json!(harness)); + let mut v = with_open_note(v); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + } + } + } if Instant::now() >= deadline { let mut v = Map::new(); v.insert("ok".into(), json!(false)); @@ -770,6 +811,18 @@ mod tests { assert_eq!(bare.values.get("selector").and_then(Value::as_str), Some("h1")); } + #[test] + fn a_dev_server_that_dies_mid_wait_gets_the_start_it_instructions() { + let mut m = Map::new(); + m.insert("error".into(), json!("dev_server_gone")); + m.insert("devUrl".into(), json!("http://127.0.0.1:5173/")); + m.insert("harness".into(), json!("cursor")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.contains("stopped answering"), "{text}"); + assert!(text.contains("http://127.0.0.1:5173/"), "{text}"); + assert!(text.contains("background terminal") && text.contains("--dev-url"), "{text}"); + } + #[test] fn browser_needed_names_the_harness_browser_and_never_a_second_one() { let mut m = Map::new(); diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 20bea0e10..7a9c00880 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1804,7 +1804,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit - **Invoked from**: `skill/reference/generate.md` (the `generate` command), as the lane's one start command after the agent opened the page in its harness's own browser: `impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000` (`--open` only on a harness with no browser tool; without `--dev-url` and without a wait, the verdict is `browser_needed` and the agent comes back with the page open). - **Args**: `--selector ` (required), `--text `, `--index ` (1-based), `--action ` (default `impeccable`), `--count ` (default 3, 1-8), `--prompt `, `--dry-run`, `--wait-for-browser `, `--no-live-bar` (body `hideLiveBar:true`: the helper sets its lifetime-wide `hideLiveBar` preference, broadcasts `{type:'live_bar', hidden:true}` to every connected overlay before the target goes out, and answers `hideLiveBar:true` on every later `connected` frame; the overlay hides its global bar accordingly and skips its "No PRODUCT.md found" connect notice, the variant controls still show, and only the helper stopping ends it), `--target ` (consumed by `enterLiveRoot`, and the boot's --target under `--boot`), `--boot` (run `live --allow-missing-context --dev-url --no-live-bar [--target]` in-process from the caller's cwd first, reusing a running helper; implies `hideLiveBar:true` on the target; the boot's `devUrl, pageFiles, projectRoot, targetPath, liveBarHidden, contextMissing, contextNote, hasProduct, product, productPath, hasDesign, design, designPath, hasSurfaceBrief, surfaceBrief, surfaceBriefPath` ride along as `boot`; a refusing boot is printed as its own payload plus `ok:false`, `bootError:` and `_instructions`, exit 1; an unparseable boot → `boot_failed` (+`exitCode`, `detail`)), `--open` (ignored, with `openIgnored:'harness browser'` on the verdict and an `_instructions` prefix saying so, when the provider id is `cursor` or `claude-code` and neither `IMPECCABLE_BROWSER` nor the config's `browser` names a browser: a harness with its own browser never gets a second window from this verb, and the generic `BROWSER` variable is not that choice; otherwise, when `/status` reports no connected client: the dev URL is the boot's `devUrl` or a fresh `dev_url::probe`; none → `no_dev_server`, exit 1; else the URL is opened without waiting for the browser (`IMPECCABLE_BROWSER`, then `browser` in `.impeccable/config.local.json` / `.impeccable/config.json` at the app root, then `BROWSER`, then `open` / `xdg-open` / `cmd /c start`; a value with a path separator runs as a program with the URL as its argument, on macOS any other value is `open -a `), recorded as `opened:{url, via}`, and `--wait-for-browser` defaults to 60000; a launch failure → `browser_open_failed` (+`url`, `detail`)), `--allow-missing-context` (accepted and ignored), `--dev-url []` (bare: ignored, the boot's own flag; with a value: the dev server the caller already knows, put first in the boot's probe list (`IMPECCABLE_DEV_URL_CANDIDATES` still wins when set) and in this verb's own probe, and reported as `devUrl` with `devUrlVerified:false` when no probe confirmed the tag on it), `--help`. A flag without a value → stdout `{"ok":false,"error":"missing_flag_value","flag":"--x"}`, exit 1. - **Env**: `IMPECCABLE_SELF` (how the boot and poll verbs are spelled in `_instructions`). -- **Behavior**: `enterLiveRoot`; local verdicts first, each pretty-printed JSON on stdout with `_instructions`, exit 1: `selector_required`, `invalid_action` (+`action`, `validActions`), `invalid_count` (+`count`), `invalid_index` (+`index`), `invalid_wait` (+`wait`); no `server.json` (or one without port/token) → `server_not_running`. With `--wait-for-browser`, `GET /status` once a second until `connectedClients > 0` or the budget ends (`no_browser_connected` + `waitedMs`); an unanswered `/status` → `server_unreachable`. Then `POST /agent-target` with `{token, selector, action, count, text?, index?, prompt?, dryRun?}` under a 20 s client cap: a transport timeout → `request_timeout` (+`detail`, browser_timeout instructions), any other transport failure → `server_unreachable` (+`detail`); a non-2xx answer → `{ok:false, error:>, ...body}`; an unparseable body → `bad_server_response` (+`status`). Before the target goes out, with no page connected (`/status` `connectedClients` 0), no `--open`, no `--wait-for-browser`, and no `--dry-run`: `browser_needed` (`devUrl` from the boot's probe, else a probe led by the hint, else the hint unverified; `devUrlVerified`; `harness`, the provider id) or `no_dev_server` when no URL is known, exit 1. Its `_instructions` name the harness's own browser and never a second one: `cursor` → `browser_navigate` (reuse the tab on that origin); `claude-code` → the Browser pane (`navigate` the tab already on that origin, `tabs_context`, `preview_start` with the URL when the pane is closed); `codex` → rerun with `--open` or give the user the URL; others → the harness browser tool, else `--open` or the user; then rerun with `--wait-for-browser 60000`. `no_dev_server` names where this harness starts a server (`claude-code`: `preview_start` or the dev script; others: the dev script in a background terminal) and asks for `--dev-url ` on the rerun. Every verdict from the boot on carries `harness`. A started session (`ok:true`, not a dry run) then collects its own generate event: `GET /poll?types=generate&id=` in ≤5 s slices for up to 20 s, leased exactly as a poll leases it (the preflight scaffold runs on lease), printed as `event` with locally generated `_instructions` (the fast path), or `event:null` when it did not arrive. A 2xx answer is printed as received plus `boot`/`opened` when those ran, plus `_instructions` for `ok` (dry run; started session with `event`, pointing at the edit and at `live-poll --reply done --file --then-poll`; started session without it, pointing at `live-poll` first), `no_dev_server`, `browser_open_failed`, `no_browser_connected` (a variant when `opened` is present), `browser_timeout`, `invalid_selector`, `no_match` (wording depends on `rawMatchCount`), `ambiguous`, `index_out_of_range`, `busy`, `go_failed`, `server_stopping`; exit 0 when `ok:true`, else 1. `_instructions` are regenerated locally from the verdict, never taken from the wire. +- **Behavior**: `enterLiveRoot`; local verdicts first, each pretty-printed JSON on stdout with `_instructions`, exit 1: `selector_required`, `invalid_action` (+`action`, `validActions`), `invalid_count` (+`count`), `invalid_index` (+`index`), `invalid_wait` (+`wait`); no `server.json` (or one without port/token) → `server_not_running`. With `--wait-for-browser`, `GET /status` once a second until `connectedClients > 0` or the budget ends (`no_browser_connected` + `waitedMs`); while it waits, the dev URL it knows (the one it opened, else the boot's `devUrl`, else the probe or the caller's hint) gets a TCP connect every third tick, and two misses in a row end the wait early with `dev_server_gone` (+`devUrl`, `waitedMs`, `harness`) whose `_instructions` name the harness's way to start the dev script and rerun with `--dev-url`; an unanswered `/status` → `server_unreachable`. Then `POST /agent-target` with `{token, selector, action, count, text?, index?, prompt?, dryRun?}` under a 20 s client cap: a transport timeout → `request_timeout` (+`detail`, browser_timeout instructions), any other transport failure → `server_unreachable` (+`detail`); a non-2xx answer → `{ok:false, error:>, ...body}`; an unparseable body → `bad_server_response` (+`status`). Before the target goes out, with no page connected (`/status` `connectedClients` 0), no `--open`, no `--wait-for-browser`, and no `--dry-run`: `browser_needed` (`devUrl` from the boot's probe, else a probe led by the hint, else the hint unverified; `devUrlVerified`; `harness`, the provider id) or `no_dev_server` when no URL is known, exit 1. Its `_instructions` name the harness's own browser and never a second one: `cursor` → `browser_navigate` (reuse the tab on that origin); `claude-code` → the Browser pane (`navigate` the tab already on that origin, `tabs_context`, `preview_start` with the URL when the pane is closed); `codex` → rerun with `--open` or give the user the URL; others → the harness browser tool, else `--open` or the user; then rerun with `--wait-for-browser 60000`. `no_dev_server` names where this harness starts a server (`claude-code`: `preview_start` or the dev script; others: the dev script in a background terminal) and asks for `--dev-url ` on the rerun. Every verdict from the boot on carries `harness`. A started session (`ok:true`, not a dry run) then collects its own generate event: `GET /poll?types=generate&id=` in ≤5 s slices for up to 20 s, leased exactly as a poll leases it (the preflight scaffold runs on lease), printed as `event` with locally generated `_instructions` (the fast path), or `event:null` when it did not arrive. A 2xx answer is printed as received plus `boot`/`opened` when those ran, plus `_instructions` for `ok` (dry run; started session with `event`, pointing at the edit and at `live-poll --reply done --file --then-poll`; started session without it, pointing at `live-poll` first), `no_dev_server`, `browser_open_failed`, `no_browser_connected` (a variant when `opened` is present), `browser_timeout`, `invalid_selector`, `no_match` (wording depends on `rawMatchCount`), `ambiguous`, `index_out_of_range`, `busy`, `go_failed`, `server_stopping`; exit 0 when `ok:true`, else 1. `_instructions` are regenerated locally from the verdict, never taken from the wire. - **Tests**: `tests/oracle/cases/live-generate.mjs` (local verdicts, no-browser), `tests/live-agent-target.test.mjs` (protocol matrix against the binary), `crates/cli/tests/agent_target.rs`, `tests/live-e2e.test.mjs` (`agentTargetScenario`). #### `live-commit-manual-edits.mjs` -> `impeccable commit-manual-edits` diff --git a/skill/reference/generate.md b/skill/reference/generate.md index a6ec7fe67..4c9be6c91 100644 --- a/skill/reference/generate.md +++ b/skill/reference/generate.md @@ -61,6 +61,7 @@ Run it in the foreground in Cursor and Claude Code (it returns within the wait); Read the output in this order: `boot.product` / `boot.design` / `boot.surfaceBrief` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with `_instructions` that carry the whole plan. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours: - **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text ""` or `--index `. +- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url `. - **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`). - **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun. - **`event: null`** with `ok: true`: the event was slower than the wait; run `{{scripts_path}}/impeccable live-poll` once to collect it, then continue.