From 6362c653312cfdac06ed471565d3d4729faf179d Mon Sep 17 00:00:00 2001 From: Caspian Zhao Date: Wed, 14 Oct 2026 22:15:00 -0700 Subject: [PATCH] fix(jev): a CLI's localhost callback is not a change of product; wait once before handing back none_chosen In the daily 51-walk Dosu run the same 23 walks hand back to the supervising agent every day (10-12 to 10-14), and the supervisor then spends about 34 minutes of the hour on them. Two of the causes are the executor's own rules: - `origin_changed`: the walk pins the first page's origin, and a Dosu CLI login opens a tab that ends on `http://localhost:/callback` (or a `file://` report page). The steps expect exactly that page, and the walk handed it back unread. A page on a loopback host or a local file now counts as the walk's own once the walk has a terminal session of its own; any other origin still ends the walk, and a browser-only walk is unchanged. The callback's token is masked in Jev's request by the existing query-parameter rule. - `none_chosen`: an action round that chose none handed back at once, and a CLI's "Returning to your terminal..." page moved on to its callback a second later. It now gets the one wait for the page to move that a checkpoint the page settles neither way already gets (`movedBeforeHandBack`, one mechanism for both); unmoved, it hands back as before. Neither change lets Jev act or pass where it could not: the callback page is judged with the same holds/contradicts questions, and the wait only reads the page again. Checks: typecheck, lint (biome + discipline), pi lane. A local executor test (not committed) passes a walk that lands on a localhost callback with a terminal, still ends one without a terminal or on another site, re-reads a page that moved after none was chosen and hands back one that did not; on the previous code the two new cases fail and the controls pass. Browser-only eval regressions on the local stack: qs-notify off/on, hd-xl off and bw-cart on correct; bw-search-quoted on passed wrongly, which neither changed path touched (no terminal, no none) and which the control arm also did in earlier rounds. Claude-Session: https://claude.ai/code/session_01QXmSJBFujofueuHrdm6TdA --- packages/runner/src/runtime/jev-checklist.mjs | 58 +++++++++++-------- packages/runner/src/runtime/jev-executor.mjs | 25 +++++++- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/runner/src/runtime/jev-checklist.mjs b/packages/runner/src/runtime/jev-checklist.mjs index d12d9d4..d544565 100644 --- a/packages/runner/src/runtime/jev-checklist.mjs +++ b/packages/runner/src/runtime/jev-checklist.mjs @@ -67,13 +67,14 @@ export async function settle(run, state, laterReadings) { } /** - * Whether the current checkpoint, which reads no step left on a page that - * settles it neither way, first gets one wait for the page to move. A page can - * hold still with nothing out and still be on its way to the next one: - * A target product's create had its answer, its dialog stayed unchanged, and Get started - * opened only after the walk had read the page and handed it back. The wait - * is spent - * once per checkpoint, never on a page already waited out as busy, and never + * Whether a round about to hand back the current checkpoint — on a page that + * settles it neither way, or after an action round chose none — first gets one + * wait for the page to move. A page can hold still with nothing out and still + * be on its way to the next one: a target product's create had its answer, its + * dialog stayed unchanged, and Get started opened only after the walk had read + * the page and handed it back; a CLI's "Returning to your terminal…" page moved + * on to its callback a second after the walk chose none on it. The wait is + * spent once per checkpoint, never on a page already waited out as busy, and never * on the page the last such wait ended on: a page that keeps changing, such as * a clock or a live list, is judged once more and handed back, and so is the * next checkpoint on the page the one before settled on after its wait. @@ -97,30 +98,39 @@ async function unsettled(run, state, judgements, busyPage) { return busyPage ? "stayed_busy" : "no_step_left"; } +/** + * The one wait for the page to move a round gets before it hands back, when + * it gets one (waitFor), recorded as `reason`: a checkpoint the page settles + * neither way (judge), and an action round that chose none (the executor's + * act). True when the page moved: the walk reads it again in the next round. + * Unmoved, the round hands back on what it read before the wait, without + * asking Jev again. `waitForMove` waits for the page to move and returns + * where it ended. + */ +export async function movedBeforeHandBack(run, state, reason, waitForMove) { + const here = readingKey(run.reading); + if (!waitFor(run, state, here)) return false; + run.unsettledWaits.add(state.currentCheckpoint.id); + await record(run, "wait", { choice: reason }); + const there = readingKey(await waitForMove()); + if (there === here) return false; + run.waitedPage = there; + return true; +} + /** * Once no step remains before the current checkpoint, the page is judged and * never acted on: its verdict, or a hand-back when it settles none, after one - * wait for the page to move when it gets one (waitFor). Moved, the page is - * judged again in the next round; unmoved, it is handed back on the readings - * taken before the wait, without asking Jev again: a second reading of the - * same page could read a step left and act where the first read none, or - * settle a verdict on that one reading alone. `busyPage` is whether the page - * reads busy; `waitForMove` waits for it to move and returns where it ended. + * wait for the page to move when it gets one (movedBeforeHandBack). Unmoved, + * it is handed back on the readings taken before the wait: a second reading + * of the same page could read a step left and act where the first read none, + * or settle a verdict on that one reading alone. `busyPage` is whether the + * page reads busy. */ export async function judge(run, state, judgements, { busyPage, waitForMove }) { const verdict = verdictOf(judgements); if (verdict) return conclude(run, state, state.currentCheckpoint, verdict, judgements); - const here = readingKey(run.reading); - if (waitFor(run, state, here)) { - run.unsettledWaits.add(state.currentCheckpoint.id); - await record(run, "wait", { choice: "unsettled" }); - const reached = await waitForMove(); - const there = readingKey(reached); - if (there !== here) { - run.waitedPage = there; - return null; - } - } + if (await movedBeforeHandBack(run, state, "unsettled", waitForMove)) return null; return unsettled(run, state, judgements, busyPage); } diff --git a/packages/runner/src/runtime/jev-executor.mjs b/packages/runner/src/runtime/jev-executor.mjs index 448638e..9e0d1cd 100644 --- a/packages/runner/src/runtime/jev-executor.mjs +++ b/packages/runner/src/runtime/jev-executor.mjs @@ -5,7 +5,7 @@ import { renderJourneySteps } from "@shipbash/contracts/runtime/journey-steps.mjs"; import { splitWaiting } from "./action-requests.mjs"; import { chooseJevAction, closingOptions, pageOptions } from "./jev-actions.mjs"; -import { judge, record, settle, stepRemains } from "./jev-checklist.mjs"; +import { judge, movedBeforeHandBack, record, settle, stepRemains } from "./jev-checklist.mjs"; import { readSteps, whichStep } from "./jev-step-questions.mjs"; import { attribute, refuse, stepLedger, walkInputs } from "./jev-steps.mjs"; import { checkInterruption, movedFrom, readPage, waitOut } from "./jev-page-reading.mjs"; @@ -29,6 +29,21 @@ const BUSY_WAITS = 1; const BUSY_THRESHOLD = 0.5; // The guidance every walk starts with and returns to once a choice runs. const BEGIN = "Begin the authorized goal."; +// The hosts of this machine's own servers, where a program in the walk's +// terminal sends the browser: a CLI's sign-in callback (localhost:/callback). +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** + * Whether the reading is a page a program in this walk's terminal led the + * browser to, and so no change of product: a local file, or a server on this + * machine, once the walk has a terminal of its own. Any other origin than the + * walk's first still ends it (origin_changed). + */ +function terminalLocalPage(reading) { + if (!reading.terminal) return false; + const { protocol, hostname } = new URL(reading.url); + return protocol === "file:" || LOOPBACK_HOSTS.has(hostname); +} /** * @param {{ url: string, snapshot: string, terminal: { sessionId: string, screen: string, altScreen: boolean, running: boolean, exitCode?: number } | null }} reading @@ -179,7 +194,11 @@ async function stillOnPage(run, state, action) { * @param {boolean} fromPage whether the action was read off the page */ async function act(run, state, action, fromPage) { - if (!action || action.ref === "none") return outcome("none_chosen"); + if (!action || action.ref === "none") { + // The page may still be on its way to the next one: it gets the one wait a page that settles nothing gets. + const moved = await movedBeforeHandBack(run, state, "none_chosen", () => movedFrom(run)); + return outcome(moved ? null : "none_chosen"); + } if (action.ref === "give_up") return outcome("stayed_busy"); const [chosen, step] = await Promise.all([!fromPage || stillOnPage(run, state, action), stepFor(run, action)]); checkInterruption(run); @@ -214,7 +233,7 @@ async function walk(run) { const reading = await readPage(run); const { url } = reading; origin ??= new URL(url).origin; - if (new URL(url).origin !== origin) return "origin_changed"; + if (new URL(url).origin !== origin && !terminalLocalPage(reading)) return "origin_changed"; notePageMove(run, url, seen); if (reading.snapshot.length > 24_000) return "page_too_large"; const current = JSON.stringify([readingKey(reading), run.checklist.find((item) => item.status !== "passed")?.id]);