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.