From b71c26160550bbf9367b59c78de1bedfbe6290cf Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 13 Oct 2026 07:21:42 +0000 Subject: [PATCH] Launch is one press: no Review step, and a busy agent plugin no longer fails the launch The launch reviews its session inside the press and starts it when the review shows what the panel showed; a review that differs is held and the next press launches it. Nothing reviews without a press, so the main control never flips between Launch, Review and Reviewing on its own. Doors that validate an Agent's profile are asked again when Manifold refuses them while OMP's isolate is busy, and that refusal is said in plain words. --- README.md | 6 +- docs/configuration.md | 85 +++-- docs/status.md | 8 + plugins/code/generator/automation.tsx | 5 +- plugins/code/generator/generator-pane.tsx | 96 +++--- plugins/code/generator/launch-step.ts | 38 +-- plugins/code/generator/statement-model.ts | 95 +++--- plugins/code/generator/workbench-model.ts | 197 ++++++------ plugins/code/workflow.ts | 45 ++- plugins/scripts/verify-browser.ts | 371 ++++++++++++++-------- plugins/test/action-gate.test.ts | 28 +- plugins/test/agent-launch.test.ts | 33 ++ plugins/test/statement-model.test.ts | 71 +++-- plugins/test/verification.test.ts | 51 +-- 14 files changed, 625 insertions(+), 504 deletions(-) diff --git a/README.md b/README.md index 9f6b9d2..136b3d3 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,16 @@ quota preservation and Code-owned upstream fallback repair are not planned. - **Model checks:** a launch needs a model list checked against the account pool, and Launch runs that check itself when the list in use cannot serve it (a first launch, a model OMP no longer lists, or a lead the accounts no longer serve), then - goes on to the review and the launch. The first check asks the person to approve its + goes on to the launch. The first check asks the person to approve its cost, once: later checks within twice that charge run without asking, and Setup can withdraw the approval. A finished check is kept as that person's check of the exact account pool, so another workspace whose choices select the same pool adopts it on its first launch, without spending. When accounts change, a check renews a list that passed one on its own while the panel is open, within the approval, and Launch keeps working with the last list that passed meanwhile. -- **Agent launch:** the launch is still one press. Where an Agent Run can carry it, +- **Agent launch:** the launch is one press, with no review step: the press reviews the + session immediately before starting it, and stops only when what would run differs + from what the panel showed, saying what differs. Where an Agent Run can carry it, Code registers one Manifold Agent per sponsor and workspace profile with OMP's TUI harness, updates it when the profile changes, and launches a Run whose terminal is OMP's own terminal UI. **Sessions** heads each machine's group with these Runs: their diff --git a/docs/configuration.md b/docs/configuration.md index 2852136..1573a25 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -424,8 +424,8 @@ account) is struck and says why under the pointer ("Claude blocked until 16:55 falls back to sol:high", or "no route"); any pointed value names its exact `provider/id` and thinking level. A role name too long for its room ends in an ellipsis and says itself whole in its title; under 300 px each chain goes under its role. Routes -come from the review on display: the reviewed composition while it is current, else -the local review. +come from the review on display: a held review's composition while it stands (see the +launch below), else the local review. The **fallback chains** checkbox in routing's head (`f`) shows the chains behind each lead (`lead → fallback → fallback`) while the profile's fallbacks are on. With fallbacks @@ -457,10 +457,12 @@ dropdown, and the **launch line** (below) beside them. The launch row follows th generator's rows, and the launch line sits beside the launch, or under it when it has no room beside it, and grows downward, so the launch never moves under the pointer while its line changes. The step is the label of the next step in lower case: **launch**, -**save & launch**, **review**, **save**, **save & review**, **launch anyway** or -**review in Models**, and **checking models…**, **saving…**, **reviewing…**, -**launching…**, **resuming…** or **working…** while one runs. There is no separate verify -step: when the list in use cannot serve a launch, the launch checks the models first. +**save & launch**, **save**, **launch anyway** or **review in Models**, and **checking +models…**, **saving…**, **launching…**, **resuming…** or **working…** while one runs. +There is no separate verify step: when the list in use cannot serve a launch, the launch +checks the models first. There is no review step either: a launch reviews its session +inside the press (below), and nothing reviews without a press, so no review ever changes +the label. **The machine.** The dropdown shows the machine's name, a neutral dot that shows whether it is online and a chevron. It opens a list of the machine roster above the @@ -489,26 +491,39 @@ workspace" (its readout adds "changes stay a local preview"). Write access is th alone: without a canvas beside the panel, checking models, saving and account edits still work, and only launch and resume refuse, in neutral grey, "Open Code beside the workspace canvas to launch". **Launch anyway** is offered when a role's pools are out, -never for a lead no included account serves, which save, review and launch all refuse. +never for a lead no included account serves, which save and launch both refuse. **Review in Models** opens Models for a staged-only workspace. A launch that must check first is refused up front when the account observation cannot be read or includes no account. An edited team saves first; while the launch is pointed or pressed the readout says what the save changes ("2 changes to the profile: thinking high → max, advisor glance → off"), and -**Revert** discards the edit. **Save & launch** is offered when the projection rests -on present readings (every lead's pool judged fresh and the served providers known), -and the press then continues from the review to the launch only if the review shows the -projected leads and the same providers in the account pool; otherwise it stops and says -what differs (roles' leads, providers that joined or left the pool) as a choice: launch -with the reviewed pool or change a setting first. **Save & review** stops at the -review so the pool is seen first. Outcome and refusal lines clear whenever the team, +**Revert** discards the edit. **Save & launch** saves, then launches once the saved +revision is observed (only **Save** where no canvas can place a terminal). + +**One press.** A launch reviews the saved team's session on the destination immediately +before it starts it, in the same press (**launching…** throughout), and starts it when +the review shows what the panel showed: the same lead for every role and the same +providers in the account pool. A review that differs stops the press and is held: the +line says "What will run differs" and what differs (roles' leads, providers that joined +or left the pool, or that the accounts were not known before), with "launch again to run +it, or change a setting first"; while the launch is pointed the readout says where it +runs and its pool ("runs on : GPT 2"). The next press launches the held review +while the press's own inputs hold (team, options, machine, the saved profile and +authority), OMP's defaults and skill catalog revisions are those it was made with, the +reads it rests on succeed and its launch is still the step (the list in use serves it); +the first of them that fails drops it for good, and the press after that reviews again. +Accounts that moved after it are refused by the launch's own check (below). A press +stands on the same inputs: a change of one of them while it runs stops it, while an +observation that moves meanwhile does not, since the launch reads the accounts and +OMP's defaults again itself and refuses when they moved under its review. Choosing a +saved session does not stop a launch. Outcome and refusal lines clear whenever the team, machine or account pool changes. **Live dials.** An agent launch (see the workflow below) ends "Launched on · **dials**", the fix opening Sessions (`e`); a launch the reviewed session carried ends "Launched on · no live dials: " (auto plans are on, session options are set, not enabled on with **Enable in Setup**, your access sponsors no -agents here, agents unread). While the launch is pointed after its review, the readout +agents here, agents unread). While the launch is pointed with a held review, the readout says the same ("live dials in Sessions" or the reason). Setup's sessions row, and the "Open coding sessions" review, enable OMP's launch and harness operations together. @@ -520,7 +535,7 @@ beside the active one, "a staged model list waits in Models") are said in the re while the launch is pointed or pressed. It is written in this precedence: a check the person started (or one the launch waits on) running or its charge, a step in flight, the verb's refusal with its fix, a staged model list the verb -opens, a review that differs from what was projected or a check that changed the +opens, a held review that differs from what the panel showed or a check that changed the profile, a failure, the last outcome, and roles with no route with the one change that routes them (said once no check precedes the launch). A model list that cannot be read joins the line from the verb's refusal on, with its Retry and Models. @@ -575,12 +590,12 @@ checking state for 700 ms, and only on a deliberate single press, never the seco click of a double-click or a held key. It records the standing approval (the larger of the one held and this charge) before spending; one that cannot be recorded still spends this charge, and the next check asks again. Progress reads "Checking models with your -accounts · N of M requests". When the check is done the press goes on: it reviews the -saved team and launches it, unless the check narrowed a setting of the profile it was -pressed on, which stops the press with "The model check changed the profile" and the -settings it moved, as a choice. A check that stopped or was cancelled says so ("Model -check stopped while : ", "Model check cancelled") until the next one -starts; the launch is its retry. +accounts · N of M requests". When the check is done the press goes on to the launch, +which reviews and starts the saved team as above, unless the check narrowed a setting of +the profile it was pressed on, which stops the press with "The model check changed the +profile" and the settings it moved, as a choice. A check that stopped or was cancelled +says so ("Model check stopped while : ", "Model check cancelled") until +the next one starts; the launch is its retry. **Background checks.** The panel starts a check on its own when the workspace has been set up, the list in use no longer holds, no staged list waits, the list in use is not an @@ -602,22 +617,16 @@ the person did not approve with a press then spends nothing more, asking instead the background, ending unspent) if it is still listing models, and stopping if it is measuring them; one already past measuring keeps what it paid for. -Once the saved team on a list that serves a launch meets every launch precondition and -its review inputs (options, accounts, revisions, destination) have held still briefly, -the workbench requests the launch review itself, so Launch is one press. It never does -so for an unsaved edit, a starter or a staged catalog, and at most once per review -scope: a refused review or launch waits for an explicit Review or a changed input. - One gate decides every action: the launch, saving, checking models, approving a charge, resuming with or without the profile, opening a running session, and every edit of the team (including a recalled profile and a profile switch), the machine or the account pool. Nothing starts while a step runs, and only Approve while a charge waits; edits wait too, and a row still answers but refuses each choice with the gate's reason. A -chain asks the gate again before each later step (save, then review once the save is -observed; prepare, then open the terminal), so a team, machine, pool or authority -change mid-flight stops it with the reason. Review and launch refuse only a lead no -included account serves, as the session door does; fallbacks it would drop are noted, -never a refusal. +chain asks the gate again before each later step (save, then launch once the save is +observed; review, then prepare, then open the terminal), so a team, machine, pool or +authority change mid-flight stops it with the reason. Save and launch refuse only a +lead no included account serves, as the session door does; fallbacks it would drop are +noted, never a refusal. ### Usage @@ -939,7 +948,13 @@ session choices. `createCodeWorkflowClient(dispatch)` is React-free and uses the same ordinary Manifold action transport as the web. It composes `createCodeClient` with -`createOmpClient`; no Code server proxies an OMP action. Important flows are: +`createOmpClient`; no Code server proxies an OMP action. A door that validates an +Agent's profile (`core.access.registerAgentV2`, `updateAgentV2` and `launchRun`) and is +refused with `exclusive request requires a drained guest` or `profile validation requires +exclusive guest access` is asked again after 150, 300, 600 and 1200 ms, then the refusal +stands ("OMP was busy for a moment. Try again."): Manifold validates the profile in OMP's +isolate only while that isolate serves nothing else, and refuses rather than waits, +before the door does anything. No other door is retried. Important flows are: - **Permission review:** read OMP root destination readiness, account-owner setup/observations and gateway-owner setup independently. Build exact native @@ -994,7 +1009,7 @@ Manifold action transport as the web. It composes `createCodeClient` with panel's life; so does a machine whose `launchRun` refuses agent runs (`run_launch_protocol_unsupported`, `omp_harness_runtime_unsupported`), for that machine ("no live dials: cannot launch agent runs"). In either case the - same press, once the Run is cancelled, launches the reviewed session while its review + same press, once the Run is cancelled, launches the reviewed session while the press still stands. `readRuns(containerId)` lists the visible Code Agents named for the workspace, their Runs, each listed Run's inspection (renewals, settlement, exit code) and the terminal inventory. An open or expired Run's TUI is a running terminal of its diff --git a/docs/status.md b/docs/status.md index 741ffab..7b3601f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -102,6 +102,14 @@ files, service state, credentials or backups. a level changed in the TUI alone is not seen. A Run is attributed for at most about 13 leases. After that, or after a missed renewal, its TUI goes on detached and unattributed, with no dials. +- Manifold validates an Agent's profile in OMP's isolate (registration, update and + `launchRun`) only while that isolate serves nothing else, and refuses rather than + waits (`exclusive request requires a drained guest`). Ordinary reads of OMP from + open panels made two preview launches fail that way before Code asked such a door + again. Code now retries those two refusals for about two seconds; unit tests prove + the retry and its words against a scripted dispatch, and the disposable verifier's + registrations pass the real validation, but no busy refusal is provoked there. + Waiting instead of refusing would be Manifold's change. - The main view's presentation has these bounds. Cost is a relative list-price index, not billing, and the speed meter stays unlit and says "unmeasured" until every lead model has measured throughput. The usage pane reads again every five minutes diff --git a/plugins/code/generator/automation.tsx b/plugins/code/generator/automation.tsx index d8d10ed..e2097e0 100644 --- a/plugins/code/generator/automation.tsx +++ b/plugins/code/generator/automation.tsx @@ -8,7 +8,8 @@ export type AutomationChoice = OmpInput<"reviewSession">["automation"]; /** * Automation for the next launch or resume, in the session options sheet: one switch restricts it, * and then every native tool it may use is a switch of its own. What a restriction does and does not - * promise is always said, and once a launch was reviewed, the policy the native review made effective. + * promise is always said, and while a launch press holds a review, the policy that native review + * made effective. */ export function Automation({ choice, reviewed, refusal, change }: { choice: AutomationChoice; reviewed: OmpResult<"reviewSession">["automation"] | null; @@ -28,7 +29,7 @@ export function Automation({ choice, reviewed, refusal, change }: { )} } {choice &&

Restricted OMP tools; not an OS or network sandbox.

} - {choice &&

Task delegation and ambient skills are off; only deliberately selected sealed skills can load. Set advisor, prewalk and fallbacks to off and plans to ask first, then save the profile before reviewing.

} + {choice &&

Task delegation and ambient skills are off; only deliberately selected sealed skills can load. Set advisor, prewalk and fallbacks to off and plans to ask first, then save the profile before launching.

}

{choice ? "" : "Tool limits are not an OS or network sandbox. "}Allowed bash can execute programs, including another OMP process, under the separately granted native authority. Disabling OMP task delegation does not prohibit shell subprocesses. File tools retain their native authority; skills grant none.

Resume does not restore historical restrictions; choose restricted automation again when needed. Saved-state resume uses OMP defaults and refuses if advisor, prewalk or fallbacks remain on. Restricting changes neither the profile nor the defaults.

{reviewed &&

diff --git a/plugins/code/generator/generator-pane.tsx b/plugins/code/generator/generator-pane.tsx index f649088..e37fbdd 100644 --- a/plugins/code/generator/generator-pane.tsx +++ b/plugins/code/generator/generator-pane.tsx @@ -22,9 +22,9 @@ import { profileGroups, type LedgerRow } from "./routing-model.ts"; import { Profile, rippleTeam } from "./routing-pane.tsx"; import { generatorRows, ROW_EXTRAS, type GeneratorRow, type RowId, type RowWord } from "./rows-model.ts"; import { - changeSentence, commitKind, estimateReadouts, fixView, grounded, launchLine, launchReadout, laneFix, poolCounts, projectionOf, quotaParts, reviewDifferences, - machineSlot, reviewMatches, sameTeam, standstill, statementSlots, teamEdits, verbView, WORD_NAMES, - type EstimateReadout, type Projection, type SlotOption, type StatementContext, type StatusAction, type StatusFix, type StatusLine, type Vocabulary, + changeSentence, commitKind, estimateReadouts, fixView, launchLine, launchReadout, laneFix, poolCounts, quotaParts, reviewDifferences, + machineSlot, sameTeam, standstill, statementSlots, teamEdits, verbView, WORD_NAMES, + type EstimateReadout, type SlotOption, type StatementContext, type StatusAction, type StatusFix, type StatusLine, type Vocabulary, } from "./statement-model.ts"; import type { WorkbenchModel } from "./workbench-model.ts"; @@ -94,7 +94,7 @@ export type GeneratorPaneProps = { readonly connected: ReadonlySet | null; /** The routing's rows, which the grouped profile groups. */ readonly ledger: readonly LedgerRow[] | null; - /** The review the routing shows: the reviewed composition while current, else the local review. */ + /** The review the routing shows: a held review's composition while it stands, else the local review. */ readonly shown: Review | null; /** Routing is not shown with the generator, so the profile shows grouped under its rows. */ readonly profile: boolean; @@ -144,14 +144,11 @@ export function GeneratorPane(props: GeneratorPaneProps) { const [keySaid, setKeySaid] = useState(null); const [scrub, setScrub] = useState(null); const [launching, setLaunching] = useState(false); - // A Save & launch press that stopped at its review: the review, and what it shows unlike the projection. A Launch press - // whose check narrowed the profile stops the same way, naming the settings the check moved. - const [stopped, setStopped] = useState<{ readonly review: object; readonly differs: readonly string[]; readonly by: "review" | "check" } | null>(null); - // The press in progress: a Save & launch on its projection, or a Launch that checks the models first, on the profile it was - // pressed on, the last check failure and message before the press, and whether it has asked for its review yet. - const chain = useRef<{ readonly kind: "save"; readonly projection: Projection; readonly review: Review } - | { readonly kind: "check"; readonly wanted: Selection | null; readonly failure: VerificationFailure | null; readonly message: unknown; readonly reviewed: boolean } - | null>(null); + // A Launch press whose check narrowed the profile to what answered stopped before launching: the saved profile it stopped + // on (`profileKey`) and the settings the check moved. + const [narrowed, setNarrowed] = useState<{ readonly on: string; readonly differs: readonly string[] } | null>(null); + // A Launch press that checks the models first: the profile it was pressed on, and the last check failure and message before it. + const chain = useRef<{ readonly wanted: Selection | null; readonly failure: VerificationFailure | null; readonly message: unknown } | null>(null); const keyTimer = useRef(0), scrubTimer = useRef(0); const { compiled: catalog, selection, controlsReview, served, verification } = model; @@ -179,7 +176,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { if (!catalog || !shown) return null; const stop = standstill(catalog, shown.routes, pools); const found = stop ? rescue(catalog, shown, pools, { served, nowMs: Date.now() }) : null; - return { stop, fix: found && fixView(found, catalog, pools, vocab), grounded: grounded(catalog, shown.routes, pools, served) }; + return { stop, fix: found && fixView(found, catalog, pools, vocab) }; }, [catalog, shown, pools, served, vocab]); const estimates = useMemo(() => catalog && shown ? estimateReadouts(catalog, shown) : null, [catalog, shown]); const groups = useMemo(() => profile && ledger ? profileGroups(ledger) : null, [profile, ledger]); @@ -190,7 +187,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { serves: listServes({ verification, unservedLead: model.unservedLead }), // Launch anyway is for roles whose pools are out; a lead no account serves is the door's refusal, never "anyway". stranded: quota?.stop?.waits.some(pool => pool.verdict.kind === "blocked" || pool.verdict.kind === "maxed") ?? false, - grounded: quota?.grounded ?? false, placeable: model.placeable, launching, + placeable: model.placeable, launching, }); const ready = verb.state === "ready", reason = verb.refusal?.text ?? null; @@ -209,14 +206,21 @@ export function GeneratorPane(props: GeneratorPaneProps) { text: verification.failure.cancelled ? "Model check cancelled" : `Model check stopped while ${STEP_WORDS[verification.failure.step]}: ${verification.failure.reason}`, failed: !verification.failure.cancelled, } : null; - const shownStop = stopped !== null && stopped.review === model.launchReview ? stopped : null; + // What a narrowed stop stands on: the saved profile (a write of another revision or team drops it), the rows holding no + // edit, and the machine. Changing a setting or the machine is the stop's other way on, so either drops it for good. + const profileKey = JSON.stringify([model.record?.revision ?? null, model.record?.selection ?? null, model.localDraft !== null, model.machineId]); + const shownNarrowed = narrowed !== null && narrowed.on === profileKey ? narrowed.differs : null; + useEffect(() => { if (narrowed !== null && narrowed.on !== profileKey) setNarrowed(null); }); + // What a held review shows unlike what the panel showed, in the statement's words, while it stands. + const differs = useMemo(() => model.held && catalog + ? reviewDifferences(catalog, model.held.projected, model.held.providers, model.held.review.composition, vocab) : null, [model.held, catalog, vocab]); const line = launchLine({ verb, phase: verification.phase, mode: verification.mode, progress, charge, keepsChecks: verification.keepsChecks !== false, inFlight: model.inFlight, busy: model.busy, chaining: model.chaining, launching, machine: machine && { name: machine.name, online: machine.online, revoked: machine.revoked === true }, machineChosen: model.machineId !== "", rosterUnread: rosterError, otherMachine: other && { id: other.id, name: other.name }, message: model.message ?? stoppedVerifying, outcome: model.outcome, stop: quota?.stop ?? null, fix: quota?.fix ?? null, - differs: shownStop?.by === "review" ? shownStop.differs : null, narrowed: shownStop?.by === "check" ? shownStop.differs : null, + differs, narrowed: shownNarrowed, laneFix: verb.refusal?.code === "no-account" && slots && selection ? laneFix(slots.lane, selection) : null, nobodyServes: served !== null && served.size === 0, listFailure, ompMissing: model.ompMissing || presence?.get(model.machineId) === "absent", accountsProblem: model.accountsProblem, configurationFailed: model.configurationFailed, @@ -226,7 +230,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { pool: poolCounts(catalog, model.launchReview.composition.review.routes, model.launchReview.composition.accountPool), } : null; const readout = launchReadout({ verb, edits: saved && selection ? teamEdits(saved, selection, familyWord) : [], reviewed, dials: reviewed ? model.launchDials : null, - grounded: quota?.grounded ?? false, launchStatus: model.launchStatus }, vocab); + launchStatus: model.launchStatus }, vocab); // The line beside the launch keeps to its facts and fixes; its asides ("changes stay a local preview") are said only when the launch is pointed. const asides = line?.parts.filter(entry => entry.tone === "meta").map(entry => entry.text) ?? []; // A staged model list beside the active one changes nothing until it is reviewed in Models; the launch's words say it waits there. @@ -382,11 +386,10 @@ export function GeneratorPane(props: GeneratorPaneProps) { } } - // ------------------------------------------------------------ the launch: Save & launch, and a Launch that checks first, as one gesture + // ------------------------------------------------------------ the launch: a Launch that checks first, as one gesture function act() { - setStopped(null); - if (verb.checks) { chain.current = { kind: "check", wanted: selection, failure: verification.failure, message: model.message, reviewed: false }; setLaunching(true); } - else if (verb.launches && shown) { chain.current = { kind: "save", projection: projectionOf(shown, served), review: shown }; setLaunching(true); } + setNarrowed(null); + if (verb.checks) { chain.current = { wanted: selection, failure: verification.failure, message: model.message }; setLaunching(true); } model.actions.next(); } /** The press: a refused one shakes and says why; one that runs plays its charge and check, and the team lights up. */ @@ -416,46 +419,29 @@ export function GeneratorPane(props: GeneratorPaneProps) { button.dataset.pressing = ""; window.setTimeout(() => { delete button.dataset.pressing; fire(); }, 160); } - // A Launch that checks first goes on once its check is done: it reviews the saved team once, then launches it, unless the - // check narrowed the profile to what answered, which is said for the person to choose. A check that stopped, a failed or - // unread workspace profile, a failed review or one that left the step at review ends the press, never retries it. - // A Save & launch press goes on from its review to the launch only when the review shows what was projected. `next` asks - // the gate again before each step. A chain that ends anywhere else stops, and a review that differs is said as what - // differs, for the person to choose. + // A Launch that checks first goes on to the launch once its check is done, unless the check narrowed the profile to what + // answered, which is said for the person to choose. A check that stopped, or a failed or unread workspace profile, ends the + // press and never retries it. `next` asks the gate again, and a launch the gate refuses then is said by the verb. useLayoutEffect(() => { const pressed = chain.current; if (!pressed) return; const end = () => { chain.current = null; setLaunching(false); }; - if (pressed.kind === "check") { - if (verification.phase !== null) return; - if (verification.failure !== null && verification.failure !== pressed.failure) { end(); return; } - if (model.configurationFailed || (model.message?.failed && model.message !== pressed.message)) { end(); return; } - if (model.busy || model.chaining || !model.configurationCurrent) return; - if (model.step.step === "review") { - if (pressed.reviewed || !model.verb.open) { end(); return; } - chain.current = { ...pressed, reviewed: true }; - model.actions.next(); - return; - } - end(); - const review = model.launchReview; - if (model.step.step !== "launch" || !review) return; - const moved = pressed.wanted && model.record?.selection ? teamEdits(pressed.wanted, model.record.selection, familyWord) : []; - if (moved.length === 0) { model.actions.next(); return; } - const differs = moved.map(edit => `${WORD_NAMES[edit.word].toLowerCase()} ${edit.from} → ${edit.to}`); - setStopped({ review, differs, by: "check" }); - announce(`The model check changed the profile: ${differs.join(", ")}. Launch to go on with it, or change a setting first.`); - return; - } - if (model.busy || model.chaining) return; + if (verification.phase !== null) return; + if (verification.failure !== null && verification.failure !== pressed.failure) { end(); return; } + if (model.configurationFailed || (model.message?.failed && model.message !== pressed.message)) { end(); return; } + if (model.busy || model.chaining || !model.configurationCurrent) return; end(); - const review = model.launchReview; - if (model.step.step !== "launch" || !review || !catalog) return; - if (reviewMatches(pressed.projection, review.composition)) { model.actions.next(); return; } - const differs = reviewDifferences(catalog, pressed.review, pressed.projection.providers, review.composition, vocab); - setStopped({ review, differs, by: "review" }); - announce(`The review differs: ${differs.join("; ")}. Launch to use the reviewed pool, or change a setting first.`); + if (model.step.step !== "launch") return; + const moved = pressed.wanted && model.record?.selection ? teamEdits(pressed.wanted, model.record.selection, familyWord) : []; + if (moved.length === 0) { model.actions.next(); return; } + const settings = moved.map(edit => `${WORD_NAMES[edit.word].toLowerCase()} ${edit.from} → ${edit.to}`); + setNarrowed({ on: profileKey, differs: settings }); + announce(`The model check changed the profile: ${settings.join(", ")}. Launch to go on with it, or change a setting first.`); }); + // A press that stopped on a review unlike what the panel showed says what differs, once. + useEffect(() => { + if (differs) announce(`What will run differs: ${differs.join("; ")}. Launch again to run it, or change a setting first.`); + }, [differs]); // What the panel's keys ask of the pane, current on every render. const latest = { launch: pressFromKeys, defaults: () => resetTo("defaults"), saved: () => resetTo("saved"), recall, machines: () => picker.current?.open(), diff --git a/plugins/code/generator/launch-step.ts b/plugins/code/generator/launch-step.ts index a69b59b..65e6532 100644 --- a/plugins/code/generator/launch-step.ts +++ b/plugins/code/generator/launch-step.ts @@ -104,7 +104,6 @@ export type LaunchFacts = { */ accounts: "usable" | "unreadable" | "none"; launchReady: boolean; - previewCurrent: boolean; profile: { source: ProfileSource } | null; localDraft: { source: ProfileSource } | null; /** The shared record: only whether it holds an active and a staged catalog is read. */ @@ -128,7 +127,7 @@ export type LaunchBlockerCode = export type LaunchBlockerAction = "refresh" | "models" | "discard" | "permissions" | "options"; export type LaunchBlocker = { code: LaunchBlockerCode; text: string; action?: LaunchBlockerAction }; export type LaunchStep = - | { step: "verify" | "save" | "review" | "launch"; reason: null } + | { step: "verify" | "save" | "launch"; reason: null } | { step: "blocked"; reason: LaunchBlocker }; /** Each blocker in the main view's words: the profile, the model list, the machine; never Code's internal nouns. */ @@ -198,7 +197,7 @@ function verifyGate(facts: LaunchFacts): LaunchBlockerCode | "verify" | null { if (facts.verification.status === "verifying") return "verifying"; return verifyPreconditions(facts) ?? "verify"; } -/** Launch preconditions once the verification is current: the first unmet one, or null when review/launch may proceed. */ +/** Launch preconditions once the verification is current: the first unmet one, or null when the launch may proceed. */ function launchGate(facts: LaunchFacts): LaunchBlockerCode | null { if (facts.unsaved || !facts.record?.active) return "unsaved"; if (!facts.writable) return "read-only"; @@ -228,9 +227,10 @@ export function saveGate(facts: LaunchFacts): LaunchBlockerCode | null { * Precedence: configuration not current → staged catalog previewed → list in use cannot serve a * launch → unsaved team (or no saved active catalog) → read-only → no canvas to place a terminal → * runtime unavailable → sessions unavailable → launch permission not ready → no local model - * review → skill problems → `launch` when the review is current, else `review`. + * review → skill problems → `launch`. A launch reviews its session inside the press, immediately + * before it starts it, so a review is never a step of its own and nothing reviews without a press. * - * A check precedes saving and reviewing when the list in use cannot serve a launch + * A check precedes saving and launching when the list in use cannot serve a launch * (`listServes`): a first launch, an imported list, a routed model OMP no longer lists, or a lead * the changed accounts no longer serve. The step is then `verify`, which the Launch press runs * before going on to the launch, or the first failing check precondition in its order (checking → @@ -255,7 +255,7 @@ export function nextLaunchStep(facts: LaunchFacts): LaunchStep { if (verify === "verify") return { step: "verify", reason: null }; if (verify !== null) return blocked(verify); const gate = launchGate(facts); - if (gate === null) return { step: facts.previewCurrent ? "launch" : "review", reason: null }; + if (gate === null) return { step: "launch", reason: null }; if (gate !== "unsaved") return blocked(gate); const save = saveGate(facts); return save === null ? { step: "save", reason: null } : blocked(save); @@ -269,12 +269,12 @@ export function launchStatusText(facts: LaunchFacts): string { if (verify === "verify" && held !== "current" && held !== "verifying") return verifyText[held]; if (verify !== null && verify !== "verify") return blockerText[verify]; const gate = launchGate(facts); - return gate ? blockerText[gate] : facts.previewCurrent ? "Ready to open a terminal." : "Review before opening a terminal."; + return gate ? blockerText[gate] : "Ready to open a terminal."; } /** What the gate reads beyond the launch facts. */ export type GateFacts = LaunchFacts & { - /** A step is in flight: a save, review, launch or resume, a chain between its steps, a check the person started, a background + /** A step is in flight: a save, launch or resume, a chain between its steps, a check the person started, a background * check writing the model list, or Models writing it. */ running: boolean; /** The charge a prepared check shows and waits on, until it is approved or cancelled. */ @@ -290,7 +290,7 @@ export type GateFacts = LaunchFacts & { * team (dials, defaults, a recalled team, discarding), the session options, the destination * machine, or the account pool. */ -export type WorkbenchIntent = "verify" | "confirm" | "save" | "review" | "launch" | "resume" | "resume-with-team" | "open" +export type WorkbenchIntent = "verify" | "confirm" | "save" | "launch" | "resume" | "resume-with-team" | "open" | "edit-team" | "edit-options" | "edit-machine" | "edit-accounts"; export type GateRefusalCode = LaunchBlockerCode | "running" | "charge" | "no-charge" | "no-account" | "no-session" | "plans" | "not-next"; export type GateRefusal = { code: GateRefusalCode; text: string; action?: LaunchBlockerAction; gap?: ServiceGap }; @@ -327,7 +327,7 @@ function stopped(code: LaunchBlockerCode): GateVerdict { * `nextLaunchStep`, except that a check is judged by its own preconditions and a save by * `saveGate`. A list in use that holds may still be checked again on purpose — what OMP or the * accounts can reach changes in ways no observation proves. Save and resuming with the team stand - * on a list in use that serves a launch (`listServes`). Save, review and launch also refuse a lead + * on a list in use that serves a launch (`listServes`). Save and launch also refuse a lead * no included account serves, as the session door does, so a team the door refuses is never saved * on the way to a launch it cannot reach. * Resuming needs write access, a canvas to place the terminal, the machine and a chosen session; @@ -357,9 +357,9 @@ export function actionGate(facts: GateFacts, intent: WorkbenchIntent): GateVerdi if (save !== null) return stopped(save); return facts.unservedLead ? refuse("no-account", facts.unservedLead) : OPEN; } - case "review": case "launch": { + case "launch": { const step = nextLaunchStep(facts); - if (step.step !== intent) return closed(step, facts); + if (step.step !== "launch") return closed(step, facts); return facts.unservedLead ? refuse("no-account", facts.unservedLead) : OPEN; } case "resume": case "resume-with-team": { @@ -389,17 +389,3 @@ export function resumeTeam(withTeam: boolean, target: Target, record: Configurat if (!withTeam) return {}; return record ? { profile: { target, expectedRevision: record.revision } } : null; } - -/** How long a saved team's inputs must hold still before Code reviews its launch on its own. */ -export const AUTO_REVIEW_SETTLE_MS = 800; - -/** - * Whether Code should review the launch now, without a press: only where the gate would let the - * verb review, which needs the saved, verified team and every launch precondition, so an unsaved - * edit, a starter or a staged catalog never reviews by itself, and nothing does while a step runs - * or a lead is unserved. Once per review scope (`scope`, against the scope of the last review - * attempt): a refused review or launch waits for an explicit Review or a changed input. - */ -export function autoReviewDue(facts: GateFacts, scope: number, attempted: number | null): boolean { - return scope !== attempted && actionGate(facts, "review").open; -} diff --git a/plugins/code/generator/statement-model.ts b/plugins/code/generator/statement-model.ts index 33d8d45..83afe98 100644 --- a/plugins/code/generator/statement-model.ts +++ b/plugins/code/generator/statement-model.ts @@ -468,21 +468,12 @@ export function fixView(fix: Rescue, catalog: CompiledCatalog, pools: readonly Q // ---------------------------------------------------------------- the verb -/** Whether a launch's projection rests on present readings: every lead's pool judged fresh, and the served providers known. */ -export function grounded(catalog: CompiledCatalog, routes: readonly Route[], pools: readonly QuotaPool[], served: ReadonlySet | null): boolean { - if (served === null) return false; - return routes.every(route => { - const kind = poolOf(catalog, route.lead, pools)?.verdict.kind; - return kind !== undefined && kind !== "stale" && kind !== "unknown"; - }); -} - export type VerbFacts = { readonly step: LaunchStep; /** The gate's verdict on the step (`WorkbenchModel.verb`). */ readonly verdict: GateVerdict; readonly busy: boolean; - readonly inFlight: "save" | "review" | "launch" | "resume" | null; + readonly inFlight: "save" | "launch" | "resume" | null; readonly chaining: boolean; readonly unsaved: boolean; readonly draft: boolean; @@ -495,11 +486,9 @@ export type VerbFacts = { readonly serves: boolean; /** Some role of the team has no route now because its pools are out (`standstill`), which a launch may go ahead with. */ readonly stranded: boolean; - /** The panel's projection rests on present readings (`grounded`). */ - readonly grounded: boolean; /** A canvas beside the panel can place a terminal, so a save can go on to a launch here. */ readonly placeable: boolean; - /** A Save & launch press waits for its review before launching. */ + /** A Launch press that checks the models first is between its steps. */ readonly launching: boolean; }; export type VerbView = { @@ -509,57 +498,52 @@ export type VerbView = { readonly refusal: GateRefusal | null; /** A press saves the workspace team first. */ readonly saves: boolean; - /** A press goes on from the review to the launch when the review shows what the panel projected. */ - readonly launches: boolean; /** A press checks the models first, then goes on to the launch. */ readonly checks: boolean; /** A press opens this place instead of taking a step: a staged model list is reviewed in Models. */ readonly opens: "models" | null; }; -const RUNNING_LABELS: Readonly> = { - save: "Saving…", review: "Reviewing…", launch: "Launching…", resume: "Resuming…", +const RUNNING_LABELS: Readonly> = { + save: "Saving…", launch: "Launching…", resume: "Resuming…", }; /** * The verb for the workbench's step. The step and the gate are the model's (`nextLaunchStep`, * `actionGate`); this only names them. Launch is the verb whatever the model list needs: when the * list in use cannot serve a launch, the press checks the models first and goes on to the launch, - * and saves an edit on the way (`Save & launch`). An edited team saves first otherwise: `Save & - * launch` when the projection rests on present readings, so the press continues through the review - * to the launch, else `Save & review`, which stops at the review to show the pool, and only `Save` - * where no canvas can place the terminal. A team with a role that pools leave without a route - * launches `anyway`, never one whose lead the session door refuses. A staged model list is the one - * place the verb sends the person: `Review in Models` opens it. A refused step keeps the label of - * what it would do, with the reason. A check the panel runs on its own never takes the verb. + * and saves an edit on the way (`Save & launch`). An edited team saves first otherwise, and the + * press goes on to the launch (`Save & launch`), only `Save` where no canvas can place the terminal. + * A launch reviews its session inside the press, so no label names a review. A team with a role + * that pools leave without a route launches `anyway`, never one whose lead the session door + * refuses. A staged model list is the one place the verb sends the person: `Review in Models` + * opens it. A refused step keeps the label of what it would do, with the reason. A check the + * panel runs on its own never takes the verb. */ export function verbView(facts: VerbFacts): VerbView { - const view = (label: string, state: VerbView["state"], extra: Partial> = {}): VerbView => - ({ label, state, refusal: null, saves: false, launches: false, checks: false, opens: null, ...extra }); + const view = (label: string, state: VerbView["state"], extra: Partial> = {}): VerbView => + ({ label, state, refusal: null, saves: false, checks: false, opens: null, ...extra }); const launch = facts.edited ? "Save & launch" : "Launch"; if (facts.mode !== null && facts.mode !== "background") { if (facts.phase === "inventory" || facts.phase === "benchmark") return view("Checking models…", "busy"); if (facts.phase === "charge") return view(facts.mode === "launch" ? launch : "Check models", "waiting"); } if (facts.busy) return view(facts.inFlight ? RUNNING_LABELS[facts.inFlight] : "Working…", "busy"); - if (facts.chaining) return view("Reviewing…", "busy"); - if (facts.launching) return view("Launching…", "busy"); + if (facts.chaining || facts.launching) return view("Launching…", "busy"); if (facts.step.step === "blocked" && facts.step.reason.code === "staged") return view("Review in Models", "ready", { opens: "models" }); const step = facts.step.step; const checks = step === "verify"; const saves = step === "save" || (step === "blocked" && facts.draft && facts.unsaved && facts.serves); - const launches = saves && facts.grounded && facts.placeable; const anyway = facts.stranded && (facts.verdict.open || facts.verdict.refusal.code !== "no-account"); const label = checks ? launch - : step === "review" ? "Review" - : step === "save" ? (!facts.placeable ? "Save" : facts.grounded ? (anyway ? "Launch anyway" : "Save & launch") : "Save & review") + : step === "save" ? (!facts.placeable ? "Save" : anyway ? "Launch anyway" : "Save & launch") : step === "launch" ? (anyway ? "Launch anyway" : "Launch") : facts.step.step === "blocked" && facts.step.reason.code === "verifying" ? "Checking models…" : !facts.serves ? launch : saves ? (facts.placeable ? "Save & launch" : "Save") : "Launch"; - if (facts.verdict.open) return view(label, "ready", { saves, launches, checks }); + if (facts.verdict.open) return view(label, "ready", { saves, checks }); const waits = facts.verdict.refusal.code === "running" || facts.verdict.refusal.code === "verifying"; - return { ...view(label, waits ? "busy" : "refused", { saves, launches, checks }), refusal: facts.verdict.refusal }; + return { ...view(label, waits ? "busy" : "refused", { saves, checks }), refusal: facts.verdict.refusal }; } /** What the panel projected for a launch: each role's served lead, and the providers its account pool serves (null: unknown). */ @@ -569,9 +553,9 @@ export function projectionOf(review: Pick, served: ReadonlySet return { leads: review.routes.map(route => `${route.role}:${route.lead.key}:${route.lead.thinking}`).sort(), providers: served ? [...served].sort() : null }; } /** - * Whether a session review shows what the panel projected, so `Save & launch` may go on to the - * launch. A different lead for any role, a different set of providers in the account pool, or a - * pool the panel did not know stops the press at the review. + * Whether a session review shows what the panel showed, so a Launch press may go on from its review + * to the launch. A different lead for any role, a different set of providers in the account pool, + * or a pool the panel did not know stops the press, holding the review for the person to see. */ export function reviewMatches(projection: Projection, reviewed: { readonly review: Pick; readonly accountPool: Readonly> }): boolean { if (projection.providers === null) return false; @@ -606,16 +590,17 @@ export function movesText(catalog: CompiledCatalog, before: Review, after: Revie return [...groups.values()].map(group => group.say(group.roles.join(", "))).join(" · "); } /** - * What a session review shows unlike the projection a Save & launch was pressed on, in the statement's - * words: the roles whose lead moved, seat by seat, and the providers whose accounts joined or left - * the pool; that the pool was not known when there was no projection of it. + * What a session review shows unlike what the panel showed when the launch started (after the + * save, for a Save & launch), in the statement's words: the roles whose lead moved, seat by seat, + * and the providers whose accounts joined or left the pool; that the accounts were not known when + * the panel had no reading of them. */ export function reviewDifferences(catalog: CompiledCatalog, projected: Review, providers: readonly string[] | null, reviewed: { readonly review: Review; readonly accountPool: Readonly> }, vocab: Vocabulary): string[] { const moves = movesText(catalog, projected, reviewed.review); const now = Object.keys(reviewed.accountPool).filter(provider => reviewed.accountPool[provider]!.length > 0); const families = (list: readonly string[]) => [...new Set(list.map(provider => vocab.family(providerPolicy(provider).family)))]; - if (providers === null) return [...moves ? [moves] : [], "the pool was not known before the review"]; + if (providers === null) return [...moves ? [moves] : [], "the accounts were not known before"]; return [ ...moves ? [moves] : [], ...families(now.filter(provider => !providers.includes(provider))).map(family => `${family} accounts join the pool`), @@ -727,7 +712,7 @@ export type StatusFacts = { readonly outcome: { readonly kind: "launched"; readonly machine: string; readonly dials: "run" | AgentLaunchBlocker } | { readonly kind: "resumed"; readonly machine: string } | null; readonly stop: Standstill | null; readonly fix: FixView | null; - /** A Save & launch press stopped at a review that differs from the projection: what differs, in the statement's words. */ + /** A Launch press stopped on a review unlike what the panel showed: what differs, in the statement's words. */ readonly differs: readonly string[] | null; /** A Launch press whose check narrowed the profile to what answered stopped before launching: the settings it moved. */ readonly narrowed: readonly string[] | null; @@ -837,7 +822,7 @@ function withListFailure([first, second]: [StatusLine, StatusLine], failure: Lis /** * The line beside the launch, in precedence: a verification running or its charge (never written * over), a step in flight, the verb's refusal with its fix, a staged model list the verb opens, a - * review that differs from the projection, a failure, the outcome of the last step, and roles with + * held review unlike what the panel showed, a failure, the outcome of the last step, and roles with * no route with the fix that routes them. From the verb's refusal on, a failed model list joins it * with its own fixes. Otherwise the line is null: the verb's label, the routing and the usage say * the rest, and the readout says the verb's status while the launch is pointed. @@ -870,10 +855,9 @@ function pressLines(facts: StatusFacts, vocab: Vocabulary): [StatusLine, StatusL } if (facts.busy || facts.chaining || facts.launching) { const where = facts.machine?.name ?? "the machine"; - const text = facts.launching || facts.inFlight === "launch" ? `Opening a terminal on ${where}` - : facts.inFlight === "save" ? "Saving the workspace profile" - : facts.inFlight === "review" || facts.chaining ? `Reviewing the session on ${where}` - : facts.inFlight === "resume" ? `Resuming on ${where}` : "Working"; + const text = facts.inFlight === "save" ? "Saving the workspace profile" + : facts.inFlight === "resume" ? `Resuming on ${where}` + : facts.launching || facts.chaining || facts.inFlight === "launch" ? `Opening a terminal on ${where}` : "Working"; return [line([part(text, "busy")]), EMPTY]; } const base = baseLines(facts, vocab); @@ -884,9 +868,9 @@ function baseLines(facts: StatusFacts, vocab: Vocabulary): [StatusLine, StatusLi const { verb } = facts; if (verb.state === "refused" && verb.refusal) return refusalLines(verb.refusal, facts, vocab); if (verb.opens === "models") return [line([part("A staged model list waits in Models")]), line([part(`${verb.label.toLowerCase()} opens it; the profile stays as it is`, "meta")])]; - // A stop is a choice, not a failure: what the review shows instead, or what the check moved, and both ways on. - if (facts.differs) return [line([part("The review differs", "warn"), ...facts.differs.map(text => part(text))]), - line([part("launch to use the reviewed pool, or change a setting first", "meta")])]; + // A stop is a choice, not a failure: what would run instead, or what the check moved, and both ways on. + if (facts.differs) return [line([part("What will run differs", "warn"), ...facts.differs.map(text => part(text))]), + line([part("launch again to run it, or change a setting first", "meta")])]; if (facts.narrowed) return [line([part("The model check changed the profile", "warn"), ...facts.narrowed.map(text => part(text))]), line([part("launch to go on with it, or change a setting first", "meta")])]; if (facts.message?.failed) return [line([part(facts.message.text, "attention")]), EMPTY]; @@ -912,20 +896,17 @@ export type LaunchReadoutFacts = { readonly verb: VerbView; /** What the verb's save would change on the saved workspace profile; empty when it saves nothing. */ readonly edits: readonly TeamEdit[]; - /** The launch review on display, with its pool, until the team, machine or pool changes. */ + /** A held review, with its pool, while it stands (workbench-model.ts `HeldReview`). */ readonly reviewed: { readonly machine: string; readonly pool: readonly { readonly family: string; readonly count: number }[] } | null; - /** Whether the reviewed launch runs as an Agent Run (`run`) or why not (workflow.ts `AgentLaunchBlocker`); null with no review on display. */ + /** Whether the held review runs as an Agent Run (`run`) or why not (workflow.ts `AgentLaunchBlocker`); null with no review held. */ readonly dials: "run" | AgentLaunchBlocker | null; - /** The projection rests on present readings (`grounded`). */ - readonly grounded: boolean; /** The model's status sentence (`launchStatusText`). */ readonly launchStatus: string; }; /** * What the readout says while the launch is pointed or pressed: the refusal; else what a save - * changes on the workspace profile, the reviewed pool (or that the review shows it before anything - * runs); else the model's status sentence. + * changes on the workspace profile and what a held review runs; else the model's status sentence. */ export function launchReadout(facts: LaunchReadoutFacts, vocab: Vocabulary): { readonly text: string; readonly warn: boolean } { const { verb, edits, reviewed } = facts; @@ -937,9 +918,9 @@ export function launchReadout(facts: LaunchReadoutFacts, vocab: Vocabulary): { r } if (reviewed) { const pool = reviewed.pool.map(({ family, count }) => `${vocab.family(family)} ${count}`).join(" · "); - parts.push(`reviewed on ${reviewed.machine}${pool ? `: ${pool}` : ""}`); + parts.push(`runs on ${reviewed.machine}${pool ? `: ${pool}` : ""}`); if (facts.dials) parts.push(facts.dials === "run" ? "live dials in Sessions" : noDials(facts.dials, reviewed.machine)); - } else if (!facts.grounded && (verb.label === "Review" || verb.label === "Save & review")) parts.push("the review shows the pool before anything runs"); + } return { text: parts.length ? parts.join(" · ") : facts.launchStatus, warn: false }; } diff --git a/plugins/code/generator/workbench-model.ts b/plugins/code/generator/workbench-model.ts index 34ceab4..e363ee0 100644 --- a/plugins/code/generator/workbench-model.ts +++ b/plugins/code/generator/workbench-model.ts @@ -17,12 +17,13 @@ import { NOT_CURRENT, SAVING } from "./account-switch.ts"; import type { BoardUsage } from "./board-model.ts"; import { skillDraft, type SkillChoice } from "./skill-draft.ts"; import type { AutomationChoice } from "./automation.tsx"; -import { actionGate, autoReviewDue, AUTO_REVIEW_SETTLE_MS, draftStale, followCatalogWrite, followInitialization, followRecord, launchStatusText, nextLaunchStep, resumeTeam, +import { actionGate, draftStale, followCatalogWrite, followInitialization, followRecord, launchStatusText, listServes, nextLaunchStep, resumeTeam, type GateFacts, type GateVerdict, type LaunchStep, type ProfileSource, type SharedBase, type WorkbenchIntent } from "./launch-step.ts"; import { previewSelection } from "./dial-space.ts"; import { browserTeamStorage, readRecentTeams, recentTeamsKey, rememberLaunch, type RecentTeam } from "./recent-teams.ts"; import { browserDraftStorage, editedDraft, readStoredDraft, restoreDraft, storeDraft, storedDraftKey } from "./draft-store.ts"; import { useModelVerification, type ModelVerification } from "./model-verification.ts"; +import { projectionOf, reviewMatches } from "./statement-model.ts"; import type { ConfirmActivation } from "./verification.ts"; /** The profile the controls show: the frozen local draft, or one derived from the shared record @@ -52,13 +53,20 @@ export type WorkbenchQueries = { }; export type WorkbenchMessage = { text: string; failed: boolean }; /** A step in flight, for the verb to name it. */ -export type WorkbenchStep = "save" | "review" | "launch" | "resume"; +export type WorkbenchStep = "save" | "launch" | "resume"; /** Whether a launch has live dials: `run` when it runs as an Agent Run, else why it cannot (workflow.ts `agentLaunchBlocker`). */ export type LaunchDials = "run" | AgentLaunchBlocker; /** What the last launch or resume opened, until the team, machine or pool changes; a launch says whether it has live dials. */ export type WorkbenchOutcome = { kind: "launched"; machine: string; dials: LaunchDials } | { kind: "resumed"; machine: string }; export type ProfileState = "conflict" | "local" | "saved"; -export type EffectiveSkillMode = SessionReview["native"]["skills"]["mode"] | NonNullable["mode"]; +/** + * A session review a Launch press stopped on because it shows something other than what the panel + * showed (statement-model.ts `reviewMatches`), with what the panel showed: the roles' leads and the + * providers its pool served, null when that pool was not known. It stands while the press's own + * inputs hold (`pressEpoch`, the press scope it was made in) and with the pins `heldCurrent` names, + * and the next press launches it; any change of those drops it, and the press after that reviews again. + */ +export type HeldReview = { readonly review: SessionReview; readonly projected: Review; readonly providers: readonly string[] | null; readonly pressEpoch: number }; export type WorkbenchInput = { host: HostServices; target: Target | null; machine: MachineSummary | null; rosterError: string | null; available: boolean }; /** @@ -66,17 +74,17 @@ export type WorkbenchInput = { host: HostServices; target: Target | null; machin * it starts and, in a chain, again before each later step; a refused one does nothing. */ export type WorkbenchActions = { - /** Edit the profile controls. Re-derives a starter document for a new budget; revokes the review. */ + /** Edit the profile controls. Re-derives a starter document for a new budget; drops a held review. */ updateSelection: (value: Selection) => void; /** Recall a team this browser launched, if the current catalog still forms it exactly; otherwise say so. */ recallTeam: (team: RecentTeam) => void; /** Drop the local draft and any acknowledged CAS receipt; return to the observed shared profile. */ discardChanges: () => void; - /** The verb: prepare a verification, save then review once the save is observed, review, or launch. */ + /** The verb: prepare a verification, save then launch once the save is observed, or launch, which reviews the session first. */ next: () => void; /** Prepare a verification: inventory, then the charge, which spends nothing until `confirmCharge`. */ verify: () => void; - /** CAS-save a local edit of the active profile, without reviewing it. */ + /** CAS-save a local edit of the active profile, without launching it. */ save: () => Promise; /** Spend the verification charge the control showed (model-verification.ts `confirm`). */ confirmCharge: (activation: ConfirmActivation, shown: ChargeReview) => void; @@ -120,13 +128,15 @@ export type WorkbenchModel = { profile: ProfileDraft | null; /** The frozen local draft (starter or edit); null while the shown profile is the shared record. */ localDraft: ProfileDraft | null; - /** The review to display: the reviewed launch composition while `previewCurrent`, else `localReview`. */ + /** The review to display: the held review's composition while it stands (`held`), else `localReview`. */ review: Review | null; localReview: Review | null; /** `localReview`, or the default selection at the chosen budget so the controls stay usable when it fails. */ controlsReview: Review | null; - /** The reviewed session; non-null exactly while `previewCurrent`. */ + /** The reviewed session a press stopped on, while it stands; null otherwise. */ launchReview: SessionReview | null; + /** The review a press stopped on, with what the panel showed, while it stands (`HeldReview`). */ + held: HeldReview | null; configurationCurrent: boolean; unsaved: boolean; /** The rows hold a real edit not yet saved or discarded (draft-store.ts `editedDraft`); the panel's reads on their own wait for it. */ @@ -146,11 +156,10 @@ export type WorkbenchModel = { ompMissing: boolean; /** Native session launch permission is ready on the destination. */ launchReady: boolean; - previewCurrent: boolean; /** An action is in flight (`perform`); the verb shows `inFlight`. */ busy: boolean; inFlight: WorkbenchStep | null; - /** A save waits for its revision to be observed before its review (the verb's "Save & review"). */ + /** A save waits for its revision to be observed before its launch (the verb's "Save & launch"). */ chaining: boolean; profileState: ProfileState; stateLabel: string; @@ -174,13 +183,11 @@ export type WorkbenchModel = { /** The usage reading every pool is judged on, and the saved account choices with their guarded edit. */ usage: BoardUsage; skillChoice: SkillChoice; - /** Choices revoke the current review and clear the message. */ + /** Choices drop a held review and clear the message. */ setSkillChoice: (value: SkillChoice) => void; skillProblems: string[]; - effectiveSkillMode: EffectiveSkillMode; - effectiveSkillCount: number; automation: AutomationChoice; - /** Choices revoke the current review and clear the message. */ + /** Choices drop a held review and clear the message. */ setAutomation: (value: AutomationChoice) => void; savedSessionId: string; /** Stable identity: consumers use it as an effect dependency. */ @@ -192,7 +199,7 @@ export type WorkbenchModel = { recentTeams: readonly RecentTeam[]; /** Code's Agent Runs in this workspace (machine-web.ts `useCodeAgentRuns`): the last good read, its error, and a read again. */ agentRuns: { readonly runs: CodeRuns | null; readonly error: string | null; readonly refresh: () => void }; - /** Whether the reviewed launch would run as an Agent Run (`run`) or why not; null while nothing is reviewed. */ + /** Whether the held review's launch would run as an Agent Run (`run`) or why not; null while no review is held. */ launchDials: LaunchDials | null; }; @@ -248,7 +255,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const [accountsFailure, setAccountsFailure] = useState(null); const accountsBusy = useRef(false); const [dials, setDials] = useState(null); - const [preview, setPreview] = useState(null); + const [held, setHeld] = useState(null); const [skillChoice, setSkillChoice] = useState(undefined); const [automation, setAutomation] = useState(undefined); const [savedSessionId, setSavedSessionId] = useState(""); @@ -281,7 +288,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: current.current.target?.machineId === machineId && current.current.available; } useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - useLayoutEffect(() => { setPreview(null); setSkillChoice(undefined); setAutomation(undefined); setSavedSessionId(""); }, [machineId]); + useLayoutEffect(() => { setHeld(null); setSkillChoice(undefined); setAutomation(undefined); setSavedSessionId(""); }, [machineId]); const storedDocument = useMemo(() => record?.active?.document ?? record?.draft?.document ?? null, [record?.active?.digest, record?.draft?.digest]); const metadataKey = useMemo(() => metadata.data === null ? null : JSON.stringify(metadata.data), [metadata.data]); @@ -373,7 +380,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: configurationCurrent, selection, routed, setup: setup.data, catalogRevision: metadata.data?.revision ?? null, writable, available, edited, idle: !busy && !chaining && !writingCatalog, onVerified: (saved, mode, adopted) => { - setSavedPolicy(saved); setDials(null); setPreview(null); + setSavedPolicy(saved); setDials(null); setHeld(null); if (mode === "manual") setMessage({ text: adopted ? "Your earlier model check is in use here." : "Models checked with your accounts, and the profile saved for the workspace.", failed: false }); }, // A stopped first verification leaves the choices it created and nothing else (launch-step.ts `followInitialization`). @@ -386,14 +393,24 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const sourceGeneration = useRef({ key: metadataKey, failed: metadata.error !== null, epoch: 0 }); if (sourceGeneration.current.key !== metadataKey || sourceGeneration.current.failed !== (metadata.error !== null)) sourceGeneration.current = { key: metadataKey, failed: metadata.error !== null, epoch: sourceGeneration.current.epoch + 1 }; - // Loss and identical recovery are distinct generations. Neither can revive an - // old confirmation, even when every durable revision stays the same. - const reviewKey = JSON.stringify([generation, authority.current.epoch, draftGeneration.current.epoch, sourceGeneration.current.epoch, + // What a resume stands on, observations included. Loss and identical recovery are distinct + // generations. Neither can revive an old confirmation, even when every durable revision stays the same. + const resumeKey = JSON.stringify([generation, authority.current.epoch, draftGeneration.current.epoch, sourceGeneration.current.epoch, machineId, available, rosterError !== null, configurationCurrent, record?.revision, record?.active?.digest, record?.draft?.digest, record?.selection, defaults.data?.revision, defaults.error !== null, skillCatalog.data?.revision, skillCatalog.error !== null, setup.error !== null, setup.data === null, accountObservation, skillChoice, automation, savedSessionId, verification.held]); - const reviewScope = useRef({ key: reviewKey, epoch: 0 }); - if (reviewScope.current.key !== reviewKey) reviewScope.current = { key: reviewKey, epoch: reviewScope.current.epoch + 1 }; + const resumeScope = useRef({ key: resumeKey, epoch: 0 }); + if (resumeScope.current.key !== resumeKey) resumeScope.current = { key: resumeKey, epoch: resumeScope.current.epoch + 1 }; + // What a press stands on: what the person chose and where, the saved profile and the authority it acts with. A launch + // reads the accounts and OMP's defaults again itself and refuses when they moved under its review (workflow.ts + // `reviewedComposition`), so an observation that moves while it runs never stops it on its own. A held review stands + // on the same inputs (`HeldReview.pressEpoch`) and the pins `heldCurrent` names; accounts that moved after it are + // refused by that same check when it is launched. + const pressKey = JSON.stringify([generation, authority.current.epoch, draftGeneration.current.epoch, machineId, + record?.revision, record?.active?.digest, record?.selection, skillChoice, automation]); + const pressScope = useRef({ key: pressKey, epoch: 0 }); + if (pressScope.current.key !== pressKey) pressScope.current = { key: pressKey, epoch: pressScope.current.epoch + 1 }; + const pressEpoch = pressScope.current.epoch; // Exact CAS arbitrates shared revision changes. Observing this save's own // successful revision must not revoke its acknowledgement. Lost observations // and changed local intent still revoke policy confirmation monotonically. @@ -402,19 +419,23 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const policyScope = useRef({ key: policyKey, epoch: 0 }); if (policyScope.current.key !== policyKey) policyScope.current = { key: policyKey, epoch: policyScope.current.epoch + 1 }; const policyEpoch = policyScope.current.epoch; - const reviewedEpoch = useRef(-1); - const reviewEpoch = reviewScope.current.epoch; - const previewCurrent = preview !== null && reviewedEpoch.current === reviewEpoch && writable && available && configurationCurrent && + const resumeEpoch = resumeScope.current.epoch; + // A held review stands only while its launch is still the step: the list in use serves it, its lead included, so a press + // never runs a model check under words that say it launches. + const heldLead = held && served && compiled ? routeService(compiled, held.review.composition.review.routes, provider => served.has(provider)).lead : null; + const heldCurrent = held !== null && held.pressEpoch === pressEpoch && writable && available && configurationCurrent && defaults.error === null && skillCatalog.error === null && setup.error === null && !!record?.active && - preview.destination.machineId === machineId && preview.composition.revision === record?.revision && - preview.native.defaultsRevision === defaults.data?.revision && !unsaved && - skillProblems.length === 0 && (preview.native.skills.mode !== "selected" || preview.native.skills.catalogRevision === skillCatalog.data?.revision); - // Whether the reviewed launch can run as an Agent Run, or why not (workflow.ts `agentLaunchBlocker`). - const agentBlocker = previewCurrent ? agentLaunchBlocker(preview, harnessReady, refusedMachines.has(preview.destination.machineId), - unsponsored ? false : agentRuns.runs?.canSponsor ?? null) : null; - const effectiveSkillMode = previewCurrent ? preview.native.skills.mode : skillChoice?.mode ?? (automation ? "disabled" : "preserve"); - const effectiveSkillCount = previewCurrent ? preview.native.skills.selected.length : draftSkills.selected.length; - const shownReview = previewCurrent ? preview.composition.review : localReview; + held.review.destination.machineId === machineId && held.review.composition.revision === record?.revision && + held.review.native.defaultsRevision === defaults.data?.revision && !unsaved && skillProblems.length === 0 && + (held.review.native.skills.mode !== "selected" || held.review.native.skills.catalogRevision === skillCatalog.data?.revision) && + listServes({ verification, unservedLead: heldLead }); + // Whatever stops a held review from standing drops it for good: a read that fails and recovers never brings it back. + useEffect(() => { if (held !== null && !heldCurrent) setHeld(null); }); + const standing = heldCurrent ? held : null; + // Whether a reviewed launch can run as an Agent Run, or why not (workflow.ts `agentLaunchBlocker`). + const dialsFor = (review: SessionReview) => agentLaunchBlocker(review, harnessReady, refusedMachines.has(review.destination.machineId), + unsponsored ? false : agentRuns.runs?.canSponsor ?? null); + const shownReview = standing ? standing.review.composition.review : localReview; // The session door's rule on the team the verb acts on: a lead nobody serves refuses, fallbacks are pruned. const unservedLead = served && compiled && shownReview ? routeService(compiled, shownReview.routes, provider => served.has(provider)).lead : null; // A check the person started holds every other step; a background one only while it writes the model list, so it never @@ -422,7 +443,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const verifyRunning = (verification.phase === "inventory" || verification.phase === "benchmark") && (verification.mode !== "background" || verification.writing); const facts: GateFacts = { configurationCurrent, profile, localDraft: dials, record, unsaved, stale, writable, placeable, available, accounts: accountState, - launchReady, previewCurrent, localReview, skillProblems, queries: { setup }, verification, + launchReady, localReview, skillProblems, queries: { setup }, verification, running: busy || chaining || verifyRunning || writingCatalog, charge: verification.phase === "charge" ? verification.charge : null, unservedLead, savedSessionId, planYolo: record?.selection?.planYolo ?? false }; // A chain's later steps ask the gate again on the facts of the latest render, its own step set aside. @@ -452,7 +473,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: function applySelection(document: CatalogDocument, value: Selection) { if (!profile) return; setDials({ ...profile, document, selection: value }); - setPreview(null); setMessage(null); + setHeld(null); setMessage(null); } function updateSelection(value: Selection) { if (!profile || !allowed("edit-team")) return; @@ -471,7 +492,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: } function discardChanges() { if (!allowed("edit-team")) return; - setDials(null); setSavedPolicy(null); setPreview(null); setMessage(null); + setDials(null); setSavedPolicy(null); setHeld(null); setMessage(null); } async function save() { // Only a local edit of a verified active profile saves here; the bundled preview has no save. @@ -481,7 +502,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: containerId: host.containerId!, expectedRevision: profile.revision, selection: profile.selection, }); if (!policyCurrent()) throw new WorkflowError("The workspace changed while saving. Nothing was retried."); - setSavedPolicy(saved); setDials(null); setPreview(null); + setSavedPolicy(saved); setDials(null); setHeld(null); setMessage({ text: "The profile is saved for the workspace.", failed: false }); }); } @@ -500,34 +521,33 @@ export function useWorkbench({ host, target, machine, rosterError, available }: catch (reason) { if (mounted.current) setMessage({ text: codeOperationFailure(reason), failed: true }); } finally { pending.current = false; if (mounted.current) { setBusy(false); setInFlight(null); refresh(); } } } - // The scope of the last review attempt, explicit or automatic: a refused one waits for a press or a changed input. - const reviewAttempt = useRef(null); - // The workbench opens interactive sessions: the task is typed in the session itself, so the composition carries no prompt. - async function review() { - if (!record || !target) return; - reviewAttempt.current = reviewEpoch; - await perform("review", async () => { - const value = await codeWorkflow(host, () => destinationCurrent() && reviewScope.current.epoch === reviewEpoch && canWriteCodeWorkspace(current.current.host)) - .reviewSession(target, record.revision, "", { skills: skillChoice, automation }); - if (destinationCurrent() && reviewScope.current.epoch === reviewEpoch && value.destination.machineId === machineId) { reviewedEpoch.current = reviewEpoch; setPreview(value); } - }); - } /** - * The launch: an Agent Run when one can carry it (workflow.ts `agentLaunchBlocker`), so the session - * has live dials, activity and a lease in Sessions; else the reviewed session as before, the line - * saying why it has no dials. Either way one review is one attempt: the next launch reviews again. + * THE LAUNCH, one press: review the saved team's session on the destination, then launch it — an Agent Run when one can + * carry it (workflow.ts `agentLaunchBlocker`), so the session has live dials, activity and a lease in Sessions; else the + * reviewed session, the line saying why it has no dials. The workbench opens interactive sessions: the task is typed in + * the session itself, so the composition carries no prompt. The review is the press's own, made immediately before the + * launch, and it goes on to the launch only when it shows what the panel showed: the same lead for every role and the + * same providers in the pool (`reviewMatches`). One that differs is held and the press stops there, for the person to see + * what differs; the next press launches the held review while it stands. Nothing reviews without a press. */ async function launch() { - const reviewed = preview, blocker = agentBlocker; - if (!target || !record || !reviewed) return; + const projected = shownReview, providers = served ? [...served].sort() : null, taken = standing?.review ?? null; + if (!target || !record || !projected) return; await perform("launch", async () => { + const pressCurrent = () => destinationCurrent() && pressScope.current.epoch === pressEpoch && canWriteCodeWorkspace(current.current.host); + const reviewed = taken ?? await codeWorkflow(host, pressCurrent).reviewSession(target, record.revision, "", { skills: skillChoice, automation }); + if (!pressCurrent() || reviewed.destination.machineId !== machineId) throw new WorkflowError("code_destination_changed"); + if (taken === null && !reviewMatches(projectionOf(projected, served), reviewed.composition)) { + setHeld({ review: reviewed, projected, providers, pressEpoch }); + return; + } try { - let fallback: AgentLaunchBlocker | null = blocker; - if (blocker === null) { - try { await launchRun(reviewed); return; } + let fallback: AgentLaunchBlocker | null = dialsFor(reviewed); + if (fallback === null) { + try { await launchRun(reviewed, pressCurrent); return; } catch (reason) { // Manifold refused this caller's sponsorship before any Run existed, or the machine refused the Run, which was - // cancelled: the reviewed session is the launch, in this same press while its review still stands, and from here on. + // cancelled: the reviewed session is the launch, in this same press while it still stands, and from here on. if (sponsorRefused(reason)) { if (mounted.current) setUnsponsored(true); fallback = "sponsor"; @@ -536,21 +556,21 @@ export function useWorkbench({ host, target, machine, rosterError, available }: if (mounted.current) setRefusedMachines(machines => new Set(machines).add(machineId)); fallback = "machine"; } else throw reason; - if (!destinationCurrent() || reviewScope.current.epoch !== reviewEpoch) throw reason; + if (!pressCurrent()) throw reason; } } - await launchSession(reviewed, fallback ?? "sponsor"); + await launchSession(reviewed, fallback, pressCurrent); } finally { - if (mounted.current) setPreview(null); + if (mounted.current) setHeld(null); } }); } /** Asked again on the latest facts before a terminal opens: a team, machine, pool or authority change while preparing stops the launch here. */ - function placement(machineId: string, containerId: string) { + function placement(machineId: string, containerId: string, pressCurrent: () => boolean) { const verdict = stillOpen("launch"); if (!verdict.open) throw new WorkflowError(`Nothing was opened: ${verdict.refusal.text}`); const latest = current.current; - if (!destinationCurrent() || reviewScope.current.epoch !== reviewEpoch || machineId !== latest.target?.machineId || containerId !== latest.host.containerId || + if (!pressCurrent() || machineId !== latest.target?.machineId || containerId !== latest.host.containerId || latest.host.principal.id !== host.principal.id || latest.machine?.id !== machineId || !latest.host.authoring || !canWriteCodeWorkspace(latest.host)) throw new WorkflowError("Nothing was opened: the destination changed."); return { machine: latest.machine, authoring: latest.host.authoring }; @@ -564,13 +584,12 @@ export function useWorkbench({ host, target, machine, rosterError, available }: setOutcome({ kind: "launched", machine, dials }); } } - async function launchRun(reviewed: SessionReview) { + async function launchRun(reviewed: SessionReview, pressCurrent: () => boolean) { const workflow = codeWorkflow(host); - const { run, launched: prepared } = await workflow.launchAgent(reviewed, - () => destinationCurrent() && reviewScope.current.epoch === reviewEpoch && canWriteCodeWorkspace(current.current.host)); + const { run, launched: prepared } = await workflow.launchAgent(reviewed, pressCurrent); let opened: { machine: string } | null = null; try { - const { machine, authoring } = placement(prepared.destination.machineId, reviewed.destination.containerId); + const { machine, authoring } = placement(prepared.destination.machineId, reviewed.destination.containerId, pressCurrent); if (await authoring.createTerminal(machine, prepared.runtime) === null) throw new WorkflowError("Nothing was opened: the canvas refused the terminal."); opened = { machine: machine.name }; } catch (reason) { @@ -584,9 +603,9 @@ export function useWorkbench({ host, target, machine, rosterError, available }: // The TUI is open under its Run: what is remembered of the launch never cancels it. launched(reviewed, opened.machine, "run", "OMP's terminal UI opened under an agent run. Its dials, activity and lease are in Sessions."); } - async function launchSession(reviewed: SessionReview, blocker: AgentLaunchBlocker) { + async function launchSession(reviewed: SessionReview, blocker: AgentLaunchBlocker, pressCurrent: () => boolean) { const prepared = await codeWorkflow(host).prepareSession(reviewed); - const { machine, authoring } = placement(prepared.destination.machineId, prepared.destination.containerId); + const { machine, authoring } = placement(prepared.destination.machineId, prepared.destination.containerId, pressCurrent); if (await authoring.createTerminal(machine, prepared.runtime) === null) throw new WorkflowError("Nothing was opened: the canvas refused the terminal."); launched(reviewed, machine.name, blocker, "Terminal opened. Follow the session in OMP. Optional choices were cleared for the next independent launch."); } @@ -598,27 +617,19 @@ export function useWorkbench({ host, target, machine, rosterError, available }: if (pending.current || step.step === "blocked" || !verb.open) return; switch (step.step) { case "verify": void verification.prepare("launch"); return; - case "save": setChaining(true); void save(); return; - case "review": void review(); return; + case "save": setChaining(placeable); void save(); return; case "launch": void launch(); return; } } - // `Save & review` is one gesture: the review follows once the saved revision is observed, never before, + // `Save & launch` is one gesture: the launch follows once the saved revision is observed, never before, // and only if the gate, asked again then, lets it; otherwise the chain stops and the verb says why. useEffect(() => { if (!chaining || busy) return; if (message?.failed) { setChaining(false); return; } if (!configurationCurrent) return; setChaining(false); - if (stillOpen("review").open) void review(); + if (stillOpen("launch").open) void launch(); }); - // The saved team reviews itself once its inputs settle, so Launch is one press; an edit never does (launch-step.ts). - const autoReview = autoReviewDue(facts, reviewEpoch, reviewAttempt.current); - useEffect(() => { - if (!autoReview) return; - const timer = window.setTimeout(() => { void review(); }, AUTO_REVIEW_SETTLE_MS); - return () => window.clearTimeout(timer); - }, [autoReview, reviewEpoch]); function confirmCharge(activation: ConfirmActivation, shown: ChargeReview) { if (actionGate(facts, "confirm").open) void verification.confirm(activation, shown); } @@ -627,11 +638,11 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const team = target ? resumeTeam(withTeam, target, record) : null; if (!team || !allowed(intent)) return; await perform("resume", async () => { - setPreview(null); + setHeld(null); const result = await codeWorkflow(host).resumeSession({ harness: OMP_PLUGIN_ID, machineId, sessionId: savedSessionId }, { ...team, ...(skillChoice === undefined ? {} : { skills: skillChoice }), ...(automation === undefined ? {} : { automation }), - }, () => destinationCurrent() && reviewScope.current.epoch === reviewEpoch && canWriteCodeWorkspace(current.current.host)); + }, () => destinationCurrent() && resumeScope.current.epoch === resumeEpoch && canWriteCodeWorkspace(current.current.host)); if (result.kind === "reopen") { if (destinationCurrent()) host.navigate(formatManifoldUri({ kind: "terminal", terminalId: result.terminals[0]!.id })); return; @@ -640,12 +651,12 @@ export function useWorkbench({ host, target, machine, rosterError, available }: if (!verdict.open) throw new WorkflowError(`Nothing was opened: ${verdict.refusal.text}`); const prepared = result.prepared; const latest = current.current; - if (!destinationCurrent() || reviewScope.current.epoch !== reviewEpoch || latest.host.principal.id !== host.principal.id || + if (!destinationCurrent() || resumeScope.current.epoch !== resumeEpoch || latest.host.principal.id !== host.principal.id || prepared.machineId !== latest.target?.machineId || prepared.sessionId !== savedSessionId || latest.machine?.id !== prepared.machineId || !latest.host.authoring || !canWriteCodeWorkspace(latest.host)) throw new WorkflowError("Nothing was opened: the destination or session choices changed."); if (await latest.host.authoring.createTerminal(latest.machine, prepared.runtime) === null) throw new WorkflowError("Nothing was opened: the canvas refused the terminal."); if (destinationCurrent()) { - setPreview(null); setSkillChoice(undefined); setAutomation(undefined); setSavedSessionId(""); + setHeld(null); setSkillChoice(undefined); setAutomation(undefined); setSavedSessionId(""); setMessage({ text: "Saved session opened in OMP. Per-session choices were cleared.", failed: false }); setOutcome({ kind: "resumed", machine: latest.machine.name }); } @@ -672,9 +683,9 @@ export function useWorkbench({ host, target, machine, rosterError, available }: if (mounted.current) { setAccountsPending(false); configuration.refresh(); accounts.refresh(); reading.refresh(); } } } - // Session options are part of a review's scope: changing them mid-chain would end a Save & launch with no line. - function changeSkillChoice(value: SkillChoice) { if (allowed("edit-options")) { setSkillChoice(value); setPreview(null); setMessage(null); } } - function changeAutomation(value: AutomationChoice) { if (allowed("edit-options")) { setAutomation(value); setPreview(null); setMessage(null); } } + // Session options are part of a press's scope: changing them mid-chain would end a Save & launch with no line. + function changeSkillChoice(value: SkillChoice) { if (allowed("edit-options")) { setSkillChoice(value); setHeld(null); setMessage(null); } } + function changeAutomation(value: AutomationChoice) { if (allowed("edit-options")) { setAutomation(value); setHeld(null); setMessage(null); } } // Outcome and refusal lines describe a premise: the team, the machine and the account pool. Any change to // one of them clears them, unless the change came with the line itself (a verification saving its team). const premise = JSON.stringify([selection, machineId, record?.accounts ?? null, accountObservation, served ? [...served].sort() : null]); @@ -699,18 +710,18 @@ export function useWorkbench({ host, target, machine, rosterError, available }: return { queries: { configuration, metadata, setup, defaults, skillCatalog, accounts }, observed, record, machineId, rosterError, document, starterError: starter.error, compiled, selection, - profile, localDraft: dials, review: shownReview, localReview, controlsReview, launchReview: previewCurrent ? preview : null, + profile, localDraft: dials, review: shownReview, localReview, controlsReview, launchReview: standing?.review ?? null, held: standing, configurationCurrent, unsaved, edited, stale, writable, placeable, available, accounts: accountState, accountsProblem, - configurationFailed: configuration.error !== null, ompMissing: setup.code === "omp_operation_unavailable", launchReady, previewCurrent, busy, inFlight, chaining, + configurationFailed: configuration.error !== null, ompMissing: setup.code === "omp_operation_unavailable", launchReady, busy, inFlight, chaining, profileState, stateLabel, launchStatus: launchStatusText(facts), message, gate: (intent, sessionId) => actionGate(sessionId === undefined ? facts : { ...facts, savedSessionId: sessionId }, intent), step, verb, served, unservedLead, outcome, usage, - skillChoice, setSkillChoice: changeSkillChoice, skillProblems, effectiveSkillMode, effectiveSkillCount, + skillChoice, setSkillChoice: changeSkillChoice, skillProblems, automation, setAutomation: changeAutomation, savedSessionId, setSavedSessionId, actions: { updateSelection, recallTeam, discardChanges, next, verify, save, confirmCharge, resume, changeAccounts: edit => void changeAccounts(edit), writingCatalog: setWritingCatalog, catalogWritten, refresh, readUsage: reading.refresh }, verification, recentTeams, agentRuns, - launchDials: previewCurrent ? agentBlocker ?? "run" : null, + launchDials: standing ? dialsFor(standing.review) ?? "run" : null, }; } diff --git a/plugins/code/workflow.ts b/plugins/code/workflow.ts index 89601f1..593b750 100644 --- a/plugins/code/workflow.ts +++ b/plugins/code/workflow.ts @@ -24,7 +24,7 @@ export class WorkflowError extends Error {} /** Refusal tokens in a person's words. A token missing here is still never shown raw (`failureWords`). */ const messages: Readonly> = { code_stale_preferences: "The workspace profile changed. Read it again before saving your edit.", - code_composition_changed: "The workspace profile, account pool or OMP defaults changed. Review the session again before launching.", + code_composition_changed: "The workspace profile, accounts, or OMP's defaults or model list changed meanwhile, so nothing was opened. Try again.", code_configuration_missing: "This workspace has no Code choices yet.", code_catalog_missing: "No model list is in use yet. Review one in Models.", code_account_unavailable: "The selected accounts are unavailable or no longer resolve exactly. Review the account choices.", @@ -55,10 +55,10 @@ const messages: Readonly> = { code_service_configuration_changed: "Native service configuration changed. Read and review its current revision again.", code_service_owner_required: "Native service setup requires the root owner's current machine configuration authority.", code_invalid_service_result: "The native service returned an invalid or undisclosed result.", - code_destination_changed: "The destination changed. Review the current machine before continuing.", - omp_review_changed: "OMP's review changed. Review again.", + code_destination_changed: "The machine, workspace or profile changed meanwhile, so this stopped. Try again.", + omp_review_changed: "OMP's answer changed meanwhile, so this stopped. Try again.", omp_result_unavailable: "OMP's result is unavailable.", - omp_defaults_changed: "OMP defaults changed. Review again.", + omp_defaults_changed: "OMP's defaults changed meanwhile, so this stopped. Try again.", omp_session_unavailable: "That saved session is no longer on the machine.", omp_session_binding_changed: "That saved session moved or changed. Read the machine again.", omp_resume_plan_unsupported: "Resuming with the current profile cannot approve plans automatically.", @@ -97,6 +97,10 @@ const agentRefusals: Readonly> = { "run launch credential unavailable": "The run's launch expired before its terminal opened.", "run launch already in progress": "That run is already launching.", "harness launch target changed": "The run was created for another machine or workspace.", + // Manifold's refusals of any door while it cannot have OMP's isolate to itself (`BUSY_REFUSALS`). The doors that validate an + // Agent's profile are asked again first (`patient`), so for those these words come only once the retries have run out. + "exclusive request requires a drained guest": "OMP was busy for a moment. Try again.", + "profile validation requires exclusive guest access": "OMP was busy for a moment. Try again.", }; /** Refusals whose detail is a list of identifiers, with the label that introduces it; the list stays exactly as written. */ const listedDetails: Readonly> = { @@ -112,6 +116,33 @@ const REFUSAL_TOKEN = /^((?:code|omp)_[a-z0-9_]+)(?:[:;] ([^]*))?$/; export function refusalToken(message: string): string | null { return REFUSAL_TOKEN.exec(DOOR_DENIAL.exec(message)?.[1] ?? message)?.[1] ?? null; } +/** + * Manifold's refusals of a door while it cannot have OMP's isolate to itself. It validates an Agent's + * profile there (registering or updating Code's Agent, and launching its Run) only when the isolate + * serves nothing else, and refuses rather than waits: the validation that finds the isolate busy, and + * any request that arrives while one runs. Ordinary reads of OMP keep the isolate busy often enough + * that a single attempt fails a launch, so the doors that validate (`VALIDATING_DOORS`) are asked + * again after each of `BUSY_RETRY_MS`. Both refusals come before the door does anything, and the + * launch asks whether its press still stands right after each of those doors. No other door is + * retried: a model check checks its cancellation and currency only before its own doors. + */ +const BUSY_REFUSALS: ReadonlySet = new Set(["exclusive request requires a drained guest", "profile validation requires exclusive guest access"]); +const VALIDATING_DOORS: ReadonlySet = new Set(["core.access.registerAgentV2", "core.access.updateAgentV2", "core.access.launchRun"]); +/** The waits before each new attempt of a door refused as busy: about two seconds in all before the refusal stands. */ +const BUSY_RETRY_MS = [150, 300, 600, 1_200] as const; +function patient(transport: Dispatch): Dispatch { + return async (door, input) => { + for (let attempt = 0; ; attempt++) { + try { return await transport(door, input); } + catch (error) { + const wait = BUSY_RETRY_MS[attempt]; + if (wait === undefined || !VALIDATING_DOORS.has(door) || !(error instanceof WorkflowError) || + !BUSY_REFUSALS.has(DOOR_DENIAL.exec(error.message)?.[1] ?? "")) throw error; + await new Promise(resolve => setTimeout(resolve, wait)); + } + } + }; +} /** * A workflow failure in a person's words. A refused door's message loses the door name it was @@ -466,8 +497,10 @@ function inspected(inspection: InspectRunV2Result): NonNullable(name: K, input: ActionInput) => Promise>; /** Ordinary caller transport only. No server contexts, credentials, shell descriptors, - * grants or React state are available to this browser/headless decision path. */ -export function createCodeWorkflowClient(dispatch: Dispatch) { + * grants or React state are available to this browser/headless decision path. A door that + * validates an Agent's profile, refused while OMP's isolate is busy, is asked again (`patient`). */ +export function createCodeWorkflowClient(transport: Dispatch) { + const dispatch = patient(transport); const codeClient = createCodeClient(dispatch); const ompClient = createOmpClient(dispatch); const code: CodeCall = async (name, input) => accepted(await codeClient.call(name, input)); diff --git a/plugins/scripts/verify-browser.ts b/plugins/scripts/verify-browser.ts index bea0595..b5ae95a 100755 --- a/plugins/scripts/verify-browser.ts +++ b/plugins/scripts/verify-browser.ts @@ -72,7 +72,8 @@ press records, a check within it that asks nothing, a kept check adopted without press and on its own, and a launch that stays ready while a changed account pool is checked again in the background; Setup's readiness rows, folder jobs and the owner's classifier (an owner-class identity minted for it alone); -and launch/resume review invalidation and refusal. The preparation, configuration and execution they +and a launch that reviews its session only when pressed and launches it in the same press, and +launch/resume refusal. The preparation, configuration and execution they lead to are refused, never native execution or consent success. This is UI/authority proof, NOT provider or native execution/readiness proof.`; if (process.argv.includes("--help")) { @@ -739,6 +740,41 @@ function noNativeEffects(requests: BrowserAction[]): void { "Starter observation, edits and navigation never request classification, discovery, benchmark, consent or native execution"); } +/** + * Records what the launch shows, its label and state (`launch ready`, `launching… busy`), from now until the returned stop: + * every render is watched rather than sampled, and each change is kept once. + */ +async function watchLaunch(browser: BrowserInstance): Promise<() => Promise> { + await browser.evaluate(`(() => { + const shown = () => ${launchButton}?.querySelector('.${G}launch-label')?.textContent + ' ' + ${launchButton}?.dataset.state; + const seen = [shown()], watch = new MutationObserver(() => { if (shown() !== seen.at(-1)) seen.push(shown()); }); + watch.observe(${element(generator)}, { subtree: true, childList: true, attributes: true, characterData: true }); + globalThis.__codeLaunchSeen = { seen, watch }; + return true; + })()`); + return () => browser.evaluate("(globalThis.__codeLaunchSeen.watch.disconnect(), globalThis.__codeLaunchSeen.seen)"); +} +/** + * A saved team whose next step is the launch, left without a press for over six seconds while the panel's observations + * refresh: nothing reviews the launch, and the verb stays a ready Launch throughout. With the workspace feed live the + * panel reads on events and once a minute, so Refresh now (`r`) has it read every input again, the accounts among them, + * three times within the wait. + */ +async function launchStaysIdle(browser: BrowserInstance, trace: { requests: BrowserAction[] }, description: string): Promise { + await until(browser, `${description}: the launch is ready`, launchIs("launch", "ready")); + await click(browser, generatorTitle); + const from = trace.requests.length, seen = await watchLaunch(browser); + for (let pass = 0; pass < 3; pass++) { + await key(browser, "r", 82); + await Bun.sleep(2_200); + } + const shown = await seen(); + const names = trace.requests.slice(from).map(request => request.name); + assert(names.filter(name => name === "atyrode.omp.accounts.accounts").length >= 3, `${description}: the panel's observations refresh while it sits`); + assert.deepEqual(names.filter(name => name === "atyrode.omp.reviewSession"), [], `${description}: nothing reviews the launch without a press`); + assert.deepEqual(shown, ["launch ready"], `${description}: the launch stays a ready Launch throughout`); +} + type Outcome = { ok: true; result: unknown } | { ok: false; denial: { rule: string; message: string } }; type Answer = (name: string, input: Record) => Outcome | undefined | Promise; function refused(message: string): Outcome { @@ -2985,9 +3021,11 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, * - A first Launch press asks before its check spends; the approve press records the person's approval, and the same * press goes on to review the saved profile and launch it. * - In a workspace with another Claude account, a first Launch press checks within that approval and spends without - * asking. The check narrows the profile to what answered, so the press stops on what changed, and Launch goes on with it. + * asking. The check narrows the profile to what answered, so the press stops on what changed before reviewing + * anything, and Launch reviews and launches it. * - A workspace with the first account adopts the kept check without an inventory or a spend: on a first Save & launch - * press, which goes on to the launch, and, once set up, on its own as it opens. + * press, which goes on to the launch, and, once set up, on its own as it opens, after which nothing reviews the launch + * until it is pressed. * - Once the accounts change under a list that passed a check, its lead still served, Launch stays ready and launches * while the check that renews the list runs on its own, within the approval. * - When they change again and OMP refuses the renewal's benchmark, the stop is said in Models only, Launch stays ready, @@ -3102,15 +3140,8 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, /** The doors that say what a press or the panel did about the models, and about the launch after. */ const doors = ["atyrode.code.initializeConfiguration", "atyrode.omp.startInventory", "atyrode.code.approveChecks", "atyrode.omp.startBenchmark", "atyrode.code.adoptCheck", "atyrode.code.promoteCatalog", "atyrode.omp.reviewSession", "atyrode.omp.prepareSession"]; - /** - * Those doors requested since `from`, in order, through the first session preparation: once a launch is refused the panel - * may review it again on its own, which is no part of the press. - */ - const requested = (from: number) => { - const names = trace.requests.slice(from).map(request => request.name).filter(name => doors.includes(name)); - const launched = names.indexOf("atyrode.omp.prepareSession"); - return launched === -1 ? names : names.slice(0, launched + 1); - }; + /** Those doors requested since `from`, in order. Nothing reviews a launch without a press, so none follows a refused one. */ + const requested = (from: number) => trace.requests.slice(from).map(request => request.name).filter(name => doors.includes(name)); /** The rows lead Claude only, which a Claude-only list hosts as it is: an edit of the bundled starter, which leads Mixed. */ const leadClaudeOnly = async () => { await choose(browser, "lane", "anthropic"); @@ -3164,7 +3195,7 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, // In a workspace with another Claude account, a first press checks within the approval and spends without asking. The // rows lead GPT, which the starter offers and a Claude-only list cannot host: the check narrows them, so the press stops - // on what it changed after the review, and Launch goes on with it. + // on what it changed before it reviews anything, and Launch reviews the narrowed profile and launches it. observed = fixtureAccounts([2]); await openEdited(within.id, () => choose(browser, "lane", "openai")); // Whatever happens until the press ends, the approve control never shows and the launch never waits on it. @@ -3180,8 +3211,8 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, `The stop names each setting the check moved, the lane among them: ${JSON.stringify(narrowed)}`); assert.equal(await browser.evaluate("globalThis.__codeAsked"), false, "Within the approval the check never shows approve and the launch never waits on it"); assert.deepEqual(requested(from), ["atyrode.code.initializeConfiguration", "atyrode.omp.startInventory", "atyrode.omp.startBenchmark", - "atyrode.code.promoteCatalog", "atyrode.omp.reviewSession"], - "The check within the approval spends without asking, and the press reviews the narrowed profile and stops before launching"); + "atyrode.code.promoteCatalog"], + "The check within the approval spends without asking, and the press stops on the narrowed profile before reviewing or launching it"); await pointOf(browser, launchButton); await until(browser, "pointed, the launch says it goes on with the narrowed profile", `${readout}.textContent.includes('launch to go on with it, or change a setting first')`); await pointAway(browser); @@ -3190,7 +3221,8 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, await click(browser, launchButton); await waitFor(() => prepared.length === 2, timeout, 50); await until(browser, "the launch's refusal is said beside it", failureLine); - assert.deepEqual(requested(from), ["atyrode.omp.prepareSession"], "Launch goes on with the profile as the check left it"); + assert.deepEqual(requested(from), ["atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], + "One Launch press reviews the profile as the check left it and launches it"); // A workspace with the first account adopts the person's kept check of it on a first Save & launch press: no inventory // and no charge, the kept list in use with the rows' edit, and the press goes on to the launch. @@ -3210,15 +3242,15 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, // The accounts change under the adopted list: a second Claude account joins the first, so the pool is another, and // Claude, the lead, is still served. A list that passed a check still serves a launch while the check renewing it runs on - // its own within the approval, held here at its inventory: Launch stays a launch, reviewed and ready, says the check runs - // in the background when pointed, and launches. Opening the workspace again observes the new pool. + // its own within the approval, held here at its inventory: Launch stays a ready launch, says the check runs in the + // background when pointed, and launches. Opening the workspace again observes the new pool. observed = fixtureAccounts([1, 4]); holdNextInventory = true; probes = [inventories.size, benchmarks.size]; from = trace.requests.length; await openGenerator(browser, server, pressed.id); await waitFor(() => heldInventory.held, timeout, 50); - await until(browser, "with only the accounts changed the launch stays a launch, reviewed and ready", launchIs("launch", "ready")); + await until(browser, "with only the accounts changed the launch stays a ready launch", launchIs("launch", "ready")); await pointOf(browser, launchButton); await until(browser, "pointed, the launch says a check runs in the background", `${readout}.textContent.includes('checking models in the background')`); await pointAway(browser); @@ -3238,6 +3270,7 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, // A workspace set up with the first account, its starter list not checked, adopts the person's kept check of it on its // own as it opens: adopting asks and spends nothing, no inventory reaches OMP, and Models says the list is checked. The // panel lands on the machine where OMP answers by itself, and the adoption holds the machine's choice while it runs. + // Left alone after that, the panel reviews nothing: the launch waits for a press. observed = fixtureAccounts([1]); const setUp = await callAction(server, person.token, "atyrode.code.initializeConfiguration", { containerId: opened.id, expectedRevision: 0 }); assert(setUp.ok, "The person sets up the workspace's Code choices"); @@ -3245,14 +3278,14 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, from = trace.requests.length; await openGenerator(browser, server, opened.id); await until(browser, "the launch names the machine the panel judged, where OMP answers", `${machinePicker}?.textContent === ${JSON.stringify(machineName)}`); - await waitFor(() => trace.requests.slice(from).some(request => request.name === "atyrode.omp.reviewSession"), timeout, 50); - await until(browser, "adopted on its own, the launch is reviewed and ready", launchIs("launch", "ready")); - assert.deepEqual([inventories.size, benchmarks.size], probes, "Adopting a kept check on its own reaches no inventory or benchmark"); - assert.deepEqual(requested(from), ["atyrode.code.adoptCheck", "atyrode.omp.reviewSession"], - "Opening the set-up workspace adopts the kept check, asking and spending nothing, and reviews the launch"); + await waitFor(async () => (await readConfiguration(server, person, { containerId: opened.id })).configuration?.active?.provenance != null, timeout, 100); const own = (await readConfiguration(server, person, { containerId: opened.id })).configuration?.active; assert.deepEqual([own?.digest, own?.provenance], [checked.digest, checked.provenance], "The kept list is in use as it was checked"); await headSaysChecked(checked.document, "Models says the list adopted on its own is checked, as of the check it adopted"); + await launchStaysIdle(browser, trace, "Adopted on its own and left alone"); + assert.deepEqual([inventories.size, benchmarks.size], probes, "Adopting a kept check on its own reaches no inventory or benchmark"); + assert.deepEqual(requested(from), ["atyrode.code.adoptCheck"], + "Opening the set-up workspace adopts the kept check, asking and spending nothing, and reviews nothing without a press"); // The accounts change again under the list renewed in the workspace adopted on a press: a third Claude account joins. // That list passed a check and the approval covers the charge, so a renewal starts on its own, and OMP refuses its @@ -3264,7 +3297,7 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, from = trace.requests.length; await openGenerator(browser, server, pressed.id); await waitFor(() => refusedBenchmarks.length === 1, timeout, 50); - await until(browser, "beside the stopped renewal the launch stays a launch, reviewed and ready, and says nothing of the stop", + await until(browser, "beside the stopped renewal the launch stays a ready launch and says nothing of the stop", `${launchIs("launch", "ready")} && !${failureLine} && !${lineText}.includes('Model check')`); await Bun.sleep(2_000); assert.deepEqual([inventories.size, benchmarks.size, refusedBenchmarks.length], [probes[0]! + 1, probes[1]!, 1], @@ -3288,7 +3321,7 @@ async function keptChecksScenario(browser: BrowserInstance, server: TestServer, await openGenerator(browser, server, pressed.id); await waitFor(() => heldBenchmark !== null, timeout, 50); const spending = heldBenchmark; - await until(browser, "while the renewal spends, the launch stays a launch, reviewed and ready", launchIs("launch", "ready")); + await until(browser, "while the renewal spends, the launch stays a ready launch", launchIs("launch", "ready")); await askFirst(browser, charge); await waitFor(() => heldBenchmark === null, timeout, 50); assert.deepEqual(cancels, [spending], "Ask first cancels the benchmark the renewal was spending, at its owner"); @@ -3624,10 +3657,10 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: await openOptions(browser); await turn(browser, autoPlans, true); await closeOptions(browser); - await until(browser, "an edited checked profile saves before its review, and offers the saved one back", `${launchIs("save & review", "ready")} && !!${revertButton}`); + await until(browser, "an edited checked profile saves before its launch, and offers the saved one back", `${launchIs("save & launch", "ready")} && !!${revertButton}`); // The launch says what its save changes while it is pointed: exactly the three edited settings. await pointOf(browser, launchButton); - await until(browser, "the launch's readout counts the edited settings", `/^save & review · 3 changes to the profile: /.test(${readout}.textContent)`); + await until(browser, "the launch's readout counts the edited settings", `/^save & launch · 3 changes to the profile: /.test(${readout}.textContent)`); assert.deepEqual(await browser.evaluate(`${readout}.textContent.split(': ').slice(1).join(': ').split(', ').map(change => change.split(' ')[0]).sort()`), ["extras", "thinking", "tier"], "The save changes exactly the edited settings"); await pointAway(browser); @@ -3644,7 +3677,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: } await panelReads(browser, revision); await until(browser, "own account edits leave the edit ready to save, without a conflict", - `${launchIs("save & review", "ready")} && !!${revertButton} && !${fix("conflict")} && ${profileValues} === ${JSON.stringify(edits)}`); + `${launchIs("save & launch", "ready")} && !!${revertButton} && !${fix("conflict")} && ${profileValues} === ${JSON.stringify(edits)}`); // A lead no included account serves refuses the save, as the session door would refuse its launch. The panel reads the // Claude account as disabled (a GPT one stays) while the check it stands on is not observed again: the save @@ -3655,7 +3688,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: await focusLaunch(browser); await key(browser, "r", 82); await until(browser, "a lead no account serves refuses the save and names its family, with the way to its accounts", - `${launchIs("save & review", "refused")} && ${lineText}.includes('Claude') && !!${fix("accounts")}`); + `${launchIs("save & launch", "refused")} && ${lineText}.includes('Claude') && !!${fix("accounts")}`); const said = await browser.evaluate(`${liveRegion}.textContent`); await key(browser, "Enter", 13); await until(browser, "the refused press says why", `${liveRegion}.textContent !== ${JSON.stringify(said)} && ${liveRegion}.textContent.endsWith(${launchButton}.title)`); @@ -3664,7 +3697,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: assert.equal((await readConfiguration(server, writer, first)).revision, revision, "A save refused for an unserved lead writes nothing"); observed = fixtureAccounts(); await key(browser, "r", 82); - await until(browser, "with the Claude account back the save is ready again", `${launchIs("save & review", "ready")} && ${profileValues} === ${JSON.stringify(edits)}`); + await until(browser, "with the Claude account back the save is ready again", `${launchIs("save & launch", "ready")} && ${profileValues} === ${JSON.stringify(edits)}`); // Models shows the workspace's checked list rung by rung; the accounts' management keeps an unsaved draft in its view under the accounts. await openSheet(browser, "models"); @@ -3711,13 +3744,13 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: assert.equal(await browser.evaluate(chosenKey("tier")), "elite"); assert.equal(await browser.evaluate(`${autoPlans}?.getAttribute('aria-checked')`), "true", "planYolo remains a profile choice, not a native permission"); - // Save from the second destination: the press saves, and its review stops where this machine has no permission. - await until(browser, "the edited profile can be saved from the second destination", launchIs("save & review", "ready")); + // Save from the second destination: the press saves, and its launch stops where this machine has no permission. + await until(browser, "the edited profile can be saved from the second destination", launchIs("save & launch", "ready")); const unsaved = (await readConfiguration(server, viewer, second)).revision; await focusLaunch(browser); await key(browser, "Enter", 13); await waitFor(async () => (await readConfiguration(server, viewer, second)).revision === unsaved + 1, timeout, 50); - await until(browser, "the saved profile's review stops at this machine's missing permission", launchIs("launch", "refused")); + await until(browser, "the saved profile's launch stops at this machine's missing permission", launchIs("launch", "refused")); assert.equal(await browser.evaluate(`document.activeElement === ${launchButton}`), true, "The launch keeps focus from Save through its refusal"); const saved = await readConfiguration(server, viewer, second); assert.equal(saved.configuration?.selection?.thinking, localThinking, "Second destination saves to the shared profile"); @@ -3757,12 +3790,12 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: // brings it back as a conflict: the edit stays on show, nothing is rebased, and theirs is one press away. await focusRow(browser, "thinking"); await key(browser, "ArrowLeft", 37); - await until(browser, "← lowers the saved thinking", `${chosenKey("thinking")} !== ${JSON.stringify(localThinking)} && ${launchIs("save & review", "ready")}`); + await until(browser, "← lowers the saved thinking", `${chosenKey("thinking")} !== ${JSON.stringify(localThinking)} && ${launchIs("save & launch", "ready")}`); const kept = await browser.evaluate(profileValues); await openGenerator(browser, server, first.containerId); await chooseMachine(browser, first.machineId, machineName); await until(browser, "the edited setting survives a reload, still ready to save", - `${profileValues} === ${JSON.stringify(kept)} && ${launchIs("save & review", "ready")} && !!${revertButton}`); + `${profileValues} === ${JSON.stringify(kept)} && ${launchIs("save & launch", "ready")} && !!${revertButton}`); await browser.goto("about:blank"); const current = await readConfiguration(server, writer, first); const ours = current.configuration!.selection!; @@ -3837,9 +3870,12 @@ async function canvaslessWriterScenario(browser: BrowserInstance, server: TestSe await focusRow(browser, "thinking"); await key(browser, "ArrowRight", 39); await until(browser, "the edit back is another Save", `${launchIs("save", "ready")} && ${chosenKey("thinking")} === ${JSON.stringify(thinking)}`); + const saving = await watchLaunch(browser); await key(browser, "Enter", 13, { modifiers: CTRL }); await waitFor(async () => (await readConfiguration(server, writer, target)).revision === before.revision + 2, timeout, 50); await until(browser, "the profile is saved back", launchIs("launch", "refused")); + const shown = await saving(); + assert(shown.includes("saving… busy") && !shown.includes("launching… busy"), `Without a canvas Save reads saving… and goes on to no launch: ${JSON.stringify(shown)}`); assert.deepEqual((await readConfiguration(server, writer, target)).configuration?.selection, before.configuration?.selection, "Saving back restores the exact profile"); assert.equal(await browser.evaluate(readOnly), false, "Nothing a canvas-less writer did is called read-only"); assert.deepEqual(trace.requests.filter(request => /^(atyrode\.omp\.(reviewSession|prepareSession|resumeSession)|core\.terminals\.create)$/.test(request.name)), [], @@ -4200,8 +4236,9 @@ async function configurationRecoveryScenario(browser: BrowserInstance, server: T await browser.goto(`${server.httpUrl}/p/${configured.containerId}`); const usage = ".plugin-atyrode_code_usage .plugin-atyrode_code__usage"; const retry = element(`${usage} button[data-action="atyrode.omp.accounts.usage"]`); + // Check usage is offered whatever the reads say, so the injected failure is awaited at the fixture, not inferred from it. + await waitFor(() => failures > 0, timeout, 50); await control(browser, "standalone Usage can retry before any successful configuration", retry, false); - assert(failures > 0, "The retry control follows an injected configuration failure"); assert.equal(await browser.evaluate(`${element(`${usage} [data-account-pool]`)} === null`), true); failConfiguration = false; await click(browser, retry); @@ -4593,6 +4630,10 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe sets: [{ id: "pair", title: "Reviewed pair", skillIds: ["alpha", "gamma"] }], updatedAt: 1000, updatedBy: "synthetic-owner" }; let sessionVisible = true, secondReadFails = false, terminalInventoryFailed = false, firstReads = 0; + // The accounts OMP reports are the shared scenario's Claude ones, read afresh each time; once below a GPT account joins. + const gpt = { reference: { kind: "credential" as const, scope: fixtureScope, provider: "openai-codex", credentialId: 3 }, credentialId: 3, + type: "api_key" as const, identityKey: null, email: null, disabled: false, blocks: [] }; + let gptJoins = false; const resumedInputs: Record[] = []; const savedSessionId = "7ab82ad4-8c9e-4166-8130-472c7cae1559"; // The first machine keeps twelve sessions in one folder, the newest of them the one resumed below, and one in another @@ -4616,7 +4657,10 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe switch (name) { case "core.machines.list": return { ok: true, result: { machines: fleetMachines } }; case "core.terminals.listAll": return terminalInventoryFailed ? refused("synthetic_terminal_inventory_failed") : { ok: true, result: { terminals: fleetTerminals } }; - case "atyrode.omp.accounts.accounts": return { ok: true, result: fixtureAccounts() }; + case "atyrode.omp.accounts.accounts": { + const claude = fixtureAccounts(); + return { ok: true, result: gptJoins ? { ...claude, accounts: [...claude.accounts, gpt] } : claude }; + } case "atyrode.omp.describeDestination": { assert.equal(input.containerId, first.containerId); assert(input.machineId === first.machineId || input.machineId === second.machineId); @@ -4670,7 +4714,21 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe const savedRow = (machineId: string) => element(`${generator} [data-kind="saved"][data-session-id="${savedSessionId}"][data-machine-id="${machineId}"]`); const rowVerb = (machineId: string, verbName: string) => `${savedRow(machineId)}?.querySelector('[data-verb="${verbName}"]')`; const runningRow = (terminalId: string) => element(`${generator} [data-kind="running"][data-terminal-id="${terminalId}"]`); - const reviewsFor = (count: number) => async () => reviews.length >= count; + const trace = await watchActions(browser, server); + /** The launch's own doors requested since `from`, in order: its review, then the session's preparation or an Agent Run's doors. */ + const launchDoors = (from: number) => trace.requests.slice(from).map(request => request.name).filter(name => + name === "atyrode.omp.reviewSession" || name === "atyrode.omp.prepareSession" || /^core\.access\.(registerAgentV2|updateAgentV2|createRunV2|launchRun)$/.test(name)); + /** + * One Launch press on the saved profile, a click unless `press` says otherwise: it reviews the session, then prepares it + * with no second press, and the fixture's refusal is said beside a ready Launch. + */ + const launchOnce = async (description: string, press: () => Promise = () => click(browser, launchButton)) => { + const from = trace.requests.length, prepared = prepareRequests; + await press(); + await waitFor(() => prepareRequests === prepared + 1, timeout, 50); + await until(browser, `${description}: the refused preparation is said beside a ready Launch`, `${failureLine} && ${launchIs("launch", "ready")}`); + assert.deepEqual(launchDoors(from), ["atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], `${description}: one press reviews the session, then launches it`); + }; try { await arrangeWorkbench(server, writer); // One recent profile, so a digit pressed where the panel's keys must not act would recall it. @@ -4679,30 +4737,19 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe await openGenerator(browser, server, first.containerId); await chooseMachine(browser, first.machineId, machineName); - // The saved profile reviews itself once its inputs settle, so Launch is one press. - await until(browser, "the saved profile is reviewed without a press", launchIs("launch", "ready")); - assert.equal(reviews.length, 1, "One automatic review of the settled saved profile"); - assert.equal(reviews[0]!.skills, undefined, "An ordinary launch carries no skill choice"); - // Pointed, the launch says where the profile was reviewed and the reviewed pool by family. - await pointOf(browser, launchButton); - // A writer who saved automatic plans launches the reviewed session, so the readout says why it will have no live dials. - await until(browser, "the launch's readout says where it was reviewed, its pool by family and why it has no live dials", - `${readout}.textContent.includes(${JSON.stringify(machineName)}) && /Claude \\d+/.test(${readout}.textContent) && ${readout}.textContent.includes('no live dials: auto plans are on')`); - await pointAway(browser); + // The saved profile's next step is the launch, and nothing reviews it before a press. + await launchStaysIdle(browser, trace, "The saved profile, left alone"); + assert.equal(reviews.length, 0, "Nothing reviews the saved profile without a press"); - // Skills: a set and an individual choice review as one effective selection. + // Skills: a set and an individual choice are one effective selection, which one press reviews and prepares as such. await openOptions(browser); await click(browser, optionSwitch("Optional skills", "Reviewed pair")); await click(browser, optionSwitch("Optional skills", "Alpha")); - await waitFor(reviewsFor(2), timeout, 50); - await until(browser, "overlapping skill selection is native-reviewed", `${launchIs("launch", "ready")} && document.querySelectorAll('${generator} [aria-label="Selected optional skills"] li').length > 0`); + await until(browser, "the overlapping skill selection is chosen", `${launchIs("launch", "ready")} && document.querySelectorAll('${generator} [aria-label="Selected optional skills"] li').length > 0`); assert.deepEqual(await browser.evaluate(`[...document.querySelectorAll('${generator} [aria-label="Selected optional skills"] li .${G}options-skill-title')].map(el => el.textContent)`), ["Alpha", "Gamma"], "A set and individual choice render one effective selection without duplicates"); await closeOptions(browser); - await focusLaunch(browser); - await key(browser, "Enter", 13); - await until(browser, "the refused preparation is said beside the launch", `${failureLine} && ${launchIs("review", "ready")}`); - assert.equal(prepareRequests, 1); + await launchOnce("↵ on the launch with skills chosen", async () => { await focusLaunch(browser); await key(browser, "Enter", 13); }); assert.equal(await browser.evaluate(`document.activeElement === ${launchButton}`), true, "The launch keeps focus through its press and its refusal"); await until(browser, "the refusal is announced", `${liveRegion}.textContent !== '' && ${lineText}.includes(${liveRegion}.textContent)`); // Outcome and refusal lines clear on any change to the profile. @@ -4713,7 +4760,8 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe await key(browser, "ArrowRight", 39); await until(browser, "the saved profile is back without a draft", `${chosenKey("thinking")} === ${JSON.stringify(savedThinking)} && !${revertButton}`); - // A declared conflict, a stale catalog and a cleared choice; disable-all reviews as such. + // A declared conflict, a stale catalog and a cleared choice; disable-all is reviewed as such by the next press, which, + // after a failed launch, reviews again. await openOptions(browser); await click(browser, optionSwitch("Optional skills", "Beta")); await until(browser, "a declared skill conflict refuses the step", `${launchButton}.dataset.state === 'refused' && !!${fix("skills")}`); @@ -4723,35 +4771,34 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe await click(browser, workspaceButton("read skills again")); await until(browser, "a stale catalog cannot silently rebase the selection", `${launchButton}.dataset.state === 'refused'`); await click(browser, workspaceButton("default skills")); - await until(browser, "a cleared choice can be reviewed again", `${launchButton}.dataset.state === 'ready'`); - const beforeDisable = reviews.length; + await until(browser, "a cleared choice makes the launch ready again", `${launchButton}.dataset.state === 'ready'`); await click(browser, workspaceButton("all skills off")); - await waitFor(reviewsFor(beforeDisable + 1), timeout, 50); - await until(browser, "disable-all is reviewed", launchIs("launch", "ready")); + await closeOptions(browser); + await launchOnce("Launch with all skills off"); assert.deepEqual(reviews.at(-1)!.skills, { mode: "disabled" }, "The actual browser sends disable-all through the ordinary workflow"); - // Restricted automation reviews as such and suppresses ambient skills; any tool change needs a new review. + // Restricted automation is reviewed as such and suppresses ambient skills; a tool changed after a launch reaches the + // next press's review, never a launch of the old one. const restricted = optionSwitch("Automation policy", "restricted automation"), readTool = optionSwitch("Automation policy", "read"); + await openOptions(browser); await turn(browser, restricted, true); await turn(browser, readTool, true); - const beforeRestricted = reviews.length; await click(browser, workspaceButton("default skills")); - await waitFor(async () => reviews.length > beforeRestricted && JSON.stringify(reviews.at(-1)!.automation) === JSON.stringify({ mode: "restricted", toolNames: ["read"], delegation: "disabled" }), timeout, 50); - await until(browser, "native restricted policy is rendered", `${element(`${generator} [data-effective-automation]`)}?.dataset.effectiveAutomation === 'restricted'`); - assert.equal(reviews.at(-1)!.skills, undefined, "Restricted review suppresses ambient defaults even without an explicit skill choice"); await closeOptions(browser); - await click(browser, launchButton); - await until(browser, "restricted preparation refusal is visible", `${failureLine} && ${launchIs("review", "ready")}`); + await launchOnce("Launch under restricted automation"); + assert.deepEqual(reviews.at(-1)!.automation, { mode: "restricted", toolNames: ["read"], delegation: "disabled" }, "The press reviews the restricted policy as chosen"); + assert.equal(reviews.at(-1)!.skills, undefined, "Restricted review suppresses ambient defaults even without an explicit skill choice"); await openOptions(browser); - const beforeTool = reviews.length; await turn(browser, readTool, false); - await waitFor(reviewsFor(beforeTool + 1), timeout, 50); - assert.deepEqual((reviews.at(-1)!.automation as { toolNames: string[] }).toolNames, [], "A tool change is reviewed again, never launched on the old review"); + await closeOptions(browser); + await launchOnce("Launch after a tool change"); + assert.deepEqual(reviews.at(-1)!.automation, { mode: "restricted", toolNames: [], delegation: "disabled" }, "A tool change is reviewed again, never launched on the old review"); + await openOptions(browser); await turn(browser, restricted, false); await closeOptions(browser); - await until(browser, "the ordinary session is reviewed", launchIs("launch", "ready")); + await until(browser, "the ordinary session is ready to launch", launchIs("launch", "ready")); - // A machine without permission refuses; returning reviews afresh, never resurrecting the old review. + // A machine without permission refuses; returning offers the launch again, and nothing reviews it before a press. const reviewsBeforeSecond = reviews.length, preparations = prepareRequests; await chooseMachine(browser, second.machineId, secondName); await until(browser, "the second destination cannot launch", `${launchButton}.dataset.state === 'refused' && !!${fix("permissions")}`); @@ -4759,16 +4806,19 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe assert.equal(reviews.length, reviewsBeforeSecond, "Choosing an unprepared machine requests no review"); assert.equal(prepareRequests, preparations, "Destination selection never prepares a launch"); await chooseMachine(browser, first.machineId, machineName); - await waitFor(reviewsFor(reviewsBeforeSecond + 1), timeout, 50); - await until(browser, "returning reviews afresh", launchIs("launch", "ready")); + await until(browser, "returning offers the launch again", launchIs("launch", "ready")); + await Bun.sleep(1_200); + assert.equal(reviews.length, reviewsBeforeSecond, "Returning to the machine requests no review before a press"); - // While a review is in flight no profile or machine edit lands, and the focused row keeps focus. + // One press held at its review: the launch says it is launching, and where, and no profile or machine edit lands + // meanwhile, the focused row keeping focus. Released, the same press goes on from its review to the launch. holdNextReview = true; - await openOptions(browser); - await click(browser, workspaceButton("all skills off")); - await closeOptions(browser); + const pressedAt = trace.requests.length, seen = await watchLaunch(browser); + await focusLaunch(browser); + await key(browser, "Enter", 13); await waitFor(() => heldReview.held, timeout, 50); - await until(browser, "the launch says the review runs", launchIs("reviewing…", "busy")); + await until(browser, "while its review runs the launch says it is launching, and on which machine", + `${launchIs("launching…", "busy")} && ${lineText}.includes(${JSON.stringify(`Opening a terminal on ${machineName}`)})`); await focusRow(browser, "advisor"); const settings = await browser.evaluate(profileValues); const announced = await browser.evaluate(`${liveRegion}.textContent`); @@ -4778,18 +4828,57 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe await key(browser, "w", 87); await Bun.sleep(300); assert.deepEqual(await browser.evaluate(`({ settings: ${profileValues}, machine: ${machinePicker}.textContent, list: ${machineList} !== null })`), - { settings, machine: machineName, list: false }, "No profile or machine edit lands while a review runs"); + { settings, machine: machineName, list: false }, "No profile or machine edit lands while a launch runs"); await focusRow(browser, "advisor"); heldReview.release(); - await until(browser, "the held review completes for the unchanged profile", launchIs("launch", "ready")); + await waitFor(() => prepareRequests === preparations + 1, timeout, 50); + await until(browser, "the same press goes on to the launch, whose refusal is said beside a ready Launch", `${failureLine} && ${launchIs("launch", "ready")}`); + assert.deepEqual(launchDoors(pressedAt), ["atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], "One press reviews the session, then launches it, with no second press"); + assert.deepEqual(await seen(), ["launch ready", "launching… busy", "launch ready"], "The launch reads launching… from the press until its refusal, then Launch again"); + assert.deepEqual([reviews.at(-1)!.skills, reviews.at(-1)!.automation], [undefined, undefined], "An ordinary launch carries no skill or automation choice"); assert.equal(await browser.evaluate(`!!${row("advisor")}.contains(document.activeElement)`), true, "Keyboard focus stays on its row through the gate"); + + // A GPT account joins the accounts OMP reports after the panel last read them, so the press's own review shows a pool + // other than the one the panel showed: the press stops there and holds that review, saying what differs, and the line + // stands while the panel reads its inputs again. Pointed, the launch says where the held review runs and why it has no + // live dials. The next press launches the held review without reviewing it again. + gptJoins = true; + const reviewsBeforeHold = reviews.length, preparedBeforeHold = prepareRequests, heldAt = trace.requests.length; + const stops = `${element(`${generator} .${G}launch-part[data-tone="warn"]`)}?.textContent === 'What will run differs'`; await click(browser, launchButton); - await until(browser, "launch refusal is shown and consumes its review", `${failureLine} && ${launchIs("review", "ready")}`); - await openOptions(browser); - await click(browser, workspaceButton("default skills")); - await closeOptions(browser); - // The cleared choice is reviewed on its own, so no review is still due when the steps below are counted. - await until(browser, "the cleared choice is reviewed", launchIs("launch", "ready")); + await until(browser, "the press stops on a review unlike what the panel showed, and says what differs", `${stops} && ${launchIs("launch", "ready")}`); + const differs = await browser.evaluate<[string, string | null][]>(launchParts); + assert(differs[0]?.[1] === "warn" && differs.length > 1, `The stop names what differs: ${JSON.stringify(differs)}`); + assert.deepEqual(launchDoors(heldAt), ["atyrode.omp.reviewSession"], "The press reviews once and stops before launching"); + await browser.evaluate(`(() => { + const held = { lost: !(${stops}), watch: new MutationObserver(() => { if (!(${stops})) held.lost = true; }) }; + held.watch.observe(${element(generator)}, { subtree: true, childList: true, attributes: true, characterData: true }); + globalThis.__codeHeld = held; + return true; + })()`); + const refreshedAt = trace.requests.length; + for (let pass = 0; pass < 2; pass++) { + await key(browser, "r", 82); + await Bun.sleep(1_700); + } + assert.equal(await browser.evaluate("(globalThis.__codeHeld.watch.disconnect(), globalThis.__codeHeld.lost)"), false, + "The held review's line stands while the panel reads its inputs again"); + assert(trace.requests.slice(refreshedAt).some(request => request.name === "atyrode.omp.accounts.accounts"), "The panel read the accounts again meanwhile"); + await pointOf(browser, launchButton); + await until(browser, "pointed, the launch says where the held review runs and why it has no live dials", + `${readout}.textContent.includes(${JSON.stringify(`runs on ${machineName}`)}) && ${readout}.textContent.includes('no live dials: auto plans are on')`); + await pointAway(browser); + const launchedAt = trace.requests.length; + await click(browser, launchButton); + await waitFor(() => prepareRequests === preparedBeforeHold + 1, timeout, 50); + await until(browser, "the next press launches the held review, whose refusal is said beside a ready Launch", `${failureLine} && ${launchIs("launch", "ready")}`); + assert.deepEqual(launchDoors(launchedAt), ["atyrode.omp.prepareSession"], "The next press launches the held review without reviewing it again"); + assert.equal(reviews.length, reviewsBeforeHold + 1, "The held review was the only review of both presses"); + // The GPT account leaves again, and the panel reads the accounts before anything below projects a launch on them. + gptJoins = false; + const leftAt = trace.requests.length; + await key(browser, "r", 82); + await waitFor(() => trace.requests.slice(leftAt).some(request => request.name === "atyrode.omp.accounts.accounts"), timeout, 50); // The sessions view: saved sessions are read per machine; only an exact terminal correlation is a running row. // A Read button goes as its read starts; focus follows the read to the machine's first row verb, never out of the panel. @@ -4948,16 +5037,17 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe assert.equal(resumedInputs.length, 1, "Unsupported profile policy must not be silently dropped"); assert.deepEqual(await readConfiguration(server, writer, first), saved, "Native observations and refusals never change saved choices"); - // Save the profile without automatic plans; the save changes only that choice. + // Save the profile without automatic plans: the save changes only that choice, and the same press goes on, once the + // saved revision is observed, to review and launch it, the launch reading saving… then launching… until it ends. await showView(browser, "main"); await openOptions(browser); await turn(browser, autoPlans, false); await closeOptions(browser); - await until(browser, "the edit saves before its review", launchIs("save & review", "ready")); - const beforeSave = reviews.length; - await click(browser, launchButton); - await waitFor(async () => (await readConfiguration(server, writer, first)).revision === saved.revision + 1, timeout, 50); - await waitFor(reviewsFor(beforeSave + 1), timeout, 50); + await until(browser, "the edit saves before its launch", launchIs("save & launch", "ready")); + const saving = await watchLaunch(browser); + await launchOnce("Save & launch"); + assert.deepEqual(await saving(), ["save & launch ready", "saving… busy", "launching… busy", "launch ready"], + "Save & launch reads saving… while it saves and launching… from then until its launch ends"); const supported = await readConfiguration(server, writer, first); assert(supported.configuration); assert.deepEqual(supported, { ...saved, revision: saved.revision + 1, configuration: { @@ -4966,14 +5056,14 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe selection: { ...saved.configuration!.selection, planYolo: false }, } }, "The explicit profile save changes only its chosen policy and revision metadata"); saved = supported; - await until(browser, "Save & review goes on to its review", launchIs("launch", "ready")); + assert.equal(reviews.at(-1)!.planYolo, false, "Save & launch reviews the profile it saved, without automatic plans"); await openOptions(browser); await turn(browser, restricted, true); await turn(browser, readTool, true); await closeOptions(browser); await showView(browser, "sessions"); await until(browser, "resuming with this profile is allowed", `${rowVerb(first.machineId, "resume-with-team")}?.getAttribute('aria-disabled') !== 'true'`); - await waitFor(async () => JSON.stringify(reviews.at(-1)?.automation) === JSON.stringify({ mode: "restricted", toolNames: ["read"], delegation: "disabled" }), timeout, 50); + // The profile resumed with is the one the last launch reviewed: the same overlay and account pool. await click(browser, rowVerb(first.machineId, "resume-with-team")); await waitFor(() => resumedInputs.length === 2, timeout, 50); await until(browser, "explicit profile resume refusal is said on the sessions view's own line", sessionSays(modelSaid)); @@ -5059,6 +5149,7 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe heldList.release(); offNavigations(); await fixture.stop(); + trace.stop(); } assert.deepEqual(await readConfiguration(server, writer, first), saved, "Synthetic observations never change real saved choices"); } @@ -5067,18 +5158,19 @@ async function syntheticPreviewScenario(browser: BrowserInstance, server: TestSe /** * A launch as an Agent Run through Manifold's real doors, then Runs this fixture cannot start, read from fixtures shaped - * on that real Agent's and Run's own reads. A human sponsor who may delegate presses the launch once: Code registers its - * Agent and creates a Run at the real server, whose `launchRun` this machine refuses for want of a native job owner; Code - * cancels the Run and the launch says why. With OMP's harness not ready the same press is the reviewed session, and the - * pointed launch says it has no live dials and why. In Sessions, synthetic Runs say their activity, their lease up to 24 - * of 24 and detached. Each Run whose TUI opened carries the model OMP's harness reports once it is up, so it reads with its - * thinking unknown. A synthetic `controlRun` takes their dials through pending, confirmed, clamped, not served, unanswered - * and sent again, and refused, and a model it switches to becomes the Run's `Run.model`, as the harness reports it. A read - * served before that report and landing after the answer keeps the answer; a model switched in the TUI shows in the read - * that carries it, dropping the answer kept for the earlier one; after a reload the first read judges the kept answers. - * ←→ settle before sending, ↵ and Space send at once, ↑↓ cross dials; a pointed word, lease or activity says itself and - * moves nothing; reduced motion stills every mark; nothing overflows from 170 to 1440px. No terminal opens and no provider - * is asked; a live Run, its renewals and its TUI remain unexercised here. + * on that real Agent's and Run's own reads. A human sponsor who may delegate presses the launch once: OMP reviews the + * session, then Code registers its Agent and creates a Run at the real server, whose `launchRun` this machine refuses for + * want of a native job owner; Code cancels the Run and the same press goes on to the reviewed session, as every later + * press on that machine does, and as a press does where OMP's harness is not ready. In Sessions, synthetic Runs say their + * activity, their lease up to 24 of 24 and detached. Each Run whose TUI opened carries the model OMP's harness reports + * once it is up, so it reads with its thinking unknown. A synthetic `controlRun` takes their dials through pending, + * confirmed, clamped, not served, unanswered and sent again, and refused, and a model it switches to becomes the Run's + * `Run.model`, as the harness reports it. A read served before that report and landing after the answer keeps the + * answer; a model switched in the TUI shows in the read that carries it, dropping the answer kept for the earlier one; + * after a reload the first read judges the kept answers. ←→ settle before sending, ↵ and Space send at once, ↑↓ cross + * dials; a pointed word, lease or activity says itself and moves nothing; reduced motion stills every mark; nothing + * overflows from 170 to 1440px. No terminal opens and no provider is asked; a live Run, its renewals and its TUI remain + * unexercised here. */ async function agentLaunchScenario(browser: BrowserInstance, server: TestServer, writer: TokenGrant, destination: Target): Promise { const workspace = await createContainer(server, "Agent launch", "canvas"); @@ -5182,27 +5274,27 @@ async function agentLaunchScenario(browser: BrowserInstance, server: TestServer, }); const agentName = await codeAgentName(workspace.id); const actions = await watchActions(browser, server); - /** The Run doors the browser called since `from`, reads left out. */ - const runDoors = (from: number) => actions.requests.slice(from).map(request => request.name) - .filter(name => name.startsWith("core.access.") && !["core.access.listAgentsV2", "core.access.listRunsV2", "core.access.inspectRunV2"].includes(name)); + /** The launch's doors the browser called since `from`, in order: the session's review and preparation, and the Agent and Run doors, reads left out. */ + const launchDoors = (from: number) => actions.requests.slice(from).map(request => request.name).filter(name => name === "atyrode.omp.reviewSession" || + name === "atyrode.omp.prepareSession" || (name.startsWith("core.access.") && !["core.access.listAgentsV2", "core.access.listRunsV2", "core.access.inspectRunV2"].includes(name))); try { await arrangeWorkbench(server, sponsor); await browser.evaluate(identity(sponsor)); await openGenerator(browser, server, workspace.id); await chooseMachine(browser, target.machineId, machineName); - // One press: the real doors register Code's Agent and create its Run; this machine refuses to launch it, Code cancels it, - // and the same press goes on to the reviewed session, which the fixture refuses at its preparation. - await until(browser, "the sponsor's saved profile is reviewed without a press", launchIs("launch", "ready")); - await pointOf(browser, launchButton); - await until(browser, "pointed, the launch says its dials will be live in Sessions", `${readout}.textContent.includes('live dials in Sessions')`); - await pointAway(browser); - const beforeLaunch = actions.requests.length; + // One press: OMP reviews the session, then the real doors register Code's Agent and create its Run; this machine refuses + // to launch it, Code cancels it, and the same press goes on to the reviewed session, which the fixture refuses at its + // preparation. The launch reads launching… throughout. + await until(browser, "the sponsor's saved profile is ready to launch", launchIs("launch", "ready")); + const beforeLaunch = actions.requests.length, launching = await watchLaunch(browser); await click(browser, launchButton); await waitFor(() => prepares === 1, timeout, 50); - await until(browser, "the same press goes on to the reviewed session, whose refusal is said beside the launch", failureLine); - assert.deepEqual(runDoors(beforeLaunch), ["core.access.registerAgentV2", "core.access.createRunV2", "core.access.launchRun", "core.access.finishAgentRunV2"], - "One press registers the Agent, creates its Run, asks Manifold to launch it and cancels it once refused"); + await until(browser, "the same press goes on to the reviewed session, whose refusal is said beside a ready Launch", `${failureLine} && ${launchIs("launch", "ready")}`); + assert.deepEqual(launchDoors(beforeLaunch), ["atyrode.omp.reviewSession", "core.access.registerAgentV2", "core.access.createRunV2", "core.access.launchRun", + "core.access.finishAgentRunV2", "atyrode.omp.prepareSession"], + "One press reviews the session, registers the Agent, creates its Run, asks Manifold to launch it, cancels it once refused and prepares the reviewed session"); + assert.deepEqual(await launching(), ["launch ready", "launching… busy", "launch ready"], "The launch reads launching… from the press until its refusal"); const agentsRead = await callAction(server, sponsor.token, "core.access.listAgentsV2", {}); assert(agentsRead.ok); const agents = ListAgentsV2ResultSchema.parse(agentsRead.result); @@ -5225,30 +5317,27 @@ async function agentLaunchScenario(browser: BrowserInstance, server: TestServer, await Bun.sleep(500); assert.equal(await browser.evaluate(`${element(`${generator} [data-run-id]`)} === null`), true, "A Run cancelled before it launched is not listed"); - // From then on this machine's launch is the reviewed session, and the pointed launch says why it has no live dials. + // From then on this machine's launch is the reviewed session: the next press reviews and prepares it, and calls no Agent + // or Run door. await showView(browser, "main"); - await until(browser, "the launch offers its next review", `${launchIs("review", "ready")} || ${launchIs("launch", "ready")}`); - if (await browser.evaluate(launchIs("review", "ready"))) await click(browser, launchButton); - await until(browser, "the launch is reviewed again", launchIs("launch", "ready")); - await pointOf(browser, launchButton); - await until(browser, "pointed, the launch says this machine cannot launch agent runs", - `${readout}.textContent.includes(${JSON.stringify(`no live dials: ${machineName} cannot launch agent runs`)})`); - await pointAway(browser); + await until(browser, "after the refused launch the launch is ready again", launchIs("launch", "ready")); const beforeFallback = actions.requests.length; await click(browser, launchButton); await waitFor(() => prepares === 2, timeout, 50); - await until(browser, "the reviewed session's refusal is said beside the launch", failureLine); - assert.deepEqual(runDoors(beforeFallback), [], "A machine that refused a Run gets the reviewed session: no Agent or Run door is called"); - // OMP's harness not ready is said before it. + await until(browser, "the reviewed session's refusal is said beside a ready Launch", `${failureLine} && ${launchIs("launch", "ready")}`); + assert.deepEqual(launchDoors(beforeFallback), ["atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], + "A machine that refused a Run gets the reviewed session: no Agent or Run door is called"); + // A panel opened afresh has no refused machine, and OMP's harness not ready there makes its launch the reviewed session too. harnessReady = false; - await key(browser, "r", 82); - await until(browser, "the launch offers its next review", `${launchIs("review", "ready")} || ${launchIs("launch", "ready")}`); - if (await browser.evaluate(launchIs("review", "ready"))) await click(browser, launchButton); - await until(browser, "the launch is reviewed again", launchIs("launch", "ready")); - await pointOf(browser, launchButton); - await until(browser, "pointed, the launch says it has no live dials because the harness is not enabled", - `${readout}.textContent.includes(${JSON.stringify(`no live dials: not enabled on ${machineName}`)})`); - await pointAway(browser); + await openGenerator(browser, server, workspace.id); + await chooseMachine(browser, target.machineId, machineName); + await until(browser, "with the harness not enabled the launch is ready", launchIs("launch", "ready")); + const beforeHarness = actions.requests.length; + await click(browser, launchButton); + await waitFor(() => prepares === 3, timeout, 50); + await until(browser, "the reviewed session's refusal is said beside a ready Launch", `${failureLine} && ${launchIs("launch", "ready")}`); + assert.deepEqual(launchDoors(beforeHarness), ["atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], + "Without OMP's harness the press gets the reviewed session: no Agent or Run door is called"); // Runs this machine cannot start, shaped on the real ones: open, at 24 of 24, renewal due, detached, starting and ended. const now = Date.now(), MINUTE = 60_000, HOUR = 60 * MINUTE; @@ -5961,7 +6050,7 @@ async function run(): Promise { const cleanupFailures = await stopEverything(); if (cleanupFailures.length) throw new Error(`Cleanup failed: ${cleanupFailures.join(", ")}${failure ? `; proof failed during ${phase}` : ""}`); if (failure) throw failure; - console.log(`PASS (${Math.round((Date.now() - started) / 1000)}s): packed Code in two real browsers and two permitted destinations; shared choices converge with viewer authority intact, and a viewer's main view and accounts refuse every write. Genuine pinned OMP metadata yields an editable render-only starter whose routing and Models list are exactly the policy derivation; a conflicted local profile stays on show without revision-zero rebasing; configuration/metadata failures are not absence, and a failed model list is named with its retry and Models whatever the launch says. A first Launch checks the models itself: its charge waits on approve, the next Tab stop after the launch, with every row and the machine locked, and a double-click, a second click, key repeat or ↵ at the panel root never approves it; the waiting line paints whole from 170 to 1440px, shifts nothing under pointer or focus, has 44px targets on a coarse pointer and animates nothing under reduced motion; one approve press records the person's approval and spends once, and its refused spend saves nothing, stays said beside the launch and is never retried on its own; Setup says how far the approval reaches, and ask first withdraws it so the next charge asks again; an account switch saves through the real changeAccounts CAS and keeps focus. A checked list recorded against the pinned OMP's artifact and the real catalog revision holds; an OMP upgrade alone, with the same accounts and Code, keeps the launch the step while Models says OMP was updated since, and a republished list that drops a routed model makes Launch check the models first. The panel reads its inputs again on its own when shown again, never while an edit is unsaved (a changed row, a named pool, until saved or discarded), a step runs or a sheet is open, and once that ends. Tiered usage windows are rows of their own that leave Claude neither tight nor maxed, and no reset is clipped where the usage grid turns to two columns. The lead row's only box toggles by a press and Space with focus kept on it, survives a lead change, is disabled under Mixed and moves no word of the row; a struck lead or box says why, and a lead whose lane on the box's side cannot run lands on its other. Models, from More with focus on its way back, shows the list as ladders whose rungs say their ids and prices only in the readout, walks them as one roving Tab stop, and offers one next action that follows the step: check models, the checking hold a double-click cannot pass, a charge approved only by one deliberate press, which records the approval, measuring per provider, checked with what it left out (Spark, by OMP's spark quota class, retired), back to code and, after ask first, check again, which Esc stops while its charge waits; a pasted list is refused until it is one, staged by one press (initializing an absent workspace once, never over a competing first save), discarded at its revision, or used by promoting exactly its review. A person's checks reach every workspace they open: after their first approve press records the approval and the press goes on to review and launch, a first check elsewhere spends within it without asking, and one that narrows the profile stops the press on what it changed before Launch goes on with it; a workspace with the same account pool adopts their kept check with no inventory or spend, on a first Save & launch press that goes on to the launch and, once set up, on its own, Models saying it is checked; a workspace never set up is never checked on its own; once the accounts change under a list that passed a check, Launch stays ready and launches while the check renewing it runs in the background within the approval; a renewal whose benchmark is refused is said in Models only, beside a launch still ready, and is never retried on its own; and Setup's ask first cancels a renewal spending under the approval at its owner, which writes nothing and starts nothing again. Setup is one checklist whose rows say what they need, pointing at the first unready one: another machine where OMP is absent, then each native review (Esc back onto the next action, nothing approved; sessions request both OMP's launch and its harness), OMP's folder job by either route, refused, and the owner's classifier reviewed by the real server before its configuration, refused; its facts say every check asks before it spends while nothing is approved. More and its Models item mark a staged list exactly when it waits beside the active one, and a staged-only workspace's launch opens Models on it. Profile and account drafts survive destinations, sheets, the accounts' management and a reload; own account edits never conflict with an unsaved edit while a foreign profile write does; a save is refused for a lead no account serves; a writer without a canvas saves and is told why launching waits, never read-only. From 170 to 1440px no view or sheet overflows, cuts or collides text, no route runs past the routing pane and More's menu stays inside the panel; pointing and focus shift nothing in a view or a sheet; More is a menu button by pointer and keyboard; keys stay panel-local and never act from a sheet or an open machine list, More menu or session drum; arrival keys reach the rows or take the launch's step; a refused keyboard step says why; the wheel steps only focused or rested rows; coarse targets are 44px in views and sheets, which hide their key line; reduced motion animates nothing and leaves no sheet duration. Auto-review, skills, automation, refused launch, resume and session correlations (one row per folder with its drum, re-read on refresh) stay gated at their native owners, native permission and folder readiness stay review-only, and no native approval, terminal or provider request is made. A sponsor's one press registers Code's Agent and creates its Run at the real doors; the machine's refused launchRun cancels the Run and the same press goes on to the reviewed session, as every later press there does, its readout saying the machine cannot launch agent runs (or, before it, that the harness is not enabled). Fixture Runs say their activity, a lease to 24 of 24, renewal due and detached, and one square on the Sessions tab; their dials go pending, queued as next, confirmed, clamped, not served, unanswered then sent again, and forbidden, and a starting or detached Run's dials send nothing; ←→ settle before one send and a press within that settle sends only itself, ↵ and Space send at once, ↑↓ cross Runs; send again keeps the said line whole on a coarse pointer; a pointed word, lease or activity moves nothing, reduced motion stills every mark, and from 170 to 1440px nothing overflows.`); + console.log(`PASS (${Math.round((Date.now() - started) / 1000)}s): packed Code in two real browsers and two permitted destinations; shared choices converge with viewer authority intact, and a viewer's main view and accounts refuse every write. Genuine pinned OMP metadata yields an editable render-only starter whose routing and Models list are exactly the policy derivation; a conflicted local profile stays on show without revision-zero rebasing; configuration/metadata failures are not absence, and a failed model list is named with its retry and Models whatever the launch says. A first Launch checks the models itself: its charge waits on approve, the next Tab stop after the launch, with every row and the machine locked, and a double-click, a second click, key repeat or ↵ at the panel root never approves it; the waiting line paints whole from 170 to 1440px, shifts nothing under pointer or focus, has 44px targets on a coarse pointer and animates nothing under reduced motion; one approve press records the person's approval and spends once, and its refused spend saves nothing, stays said beside the launch and is never retried on its own; Setup says how far the approval reaches, and ask first withdraws it so the next charge asks again; an account switch saves through the real changeAccounts CAS and keeps focus. A checked list recorded against the pinned OMP's artifact and the real catalog revision holds; an OMP upgrade alone, with the same accounts and Code, keeps the launch the step while Models says OMP was updated since, and a republished list that drops a routed model makes Launch check the models first. The panel reads its inputs again on its own when shown again, never while an edit is unsaved (a changed row, a named pool, until saved or discarded), a step runs or a sheet is open, and once that ends. Tiered usage windows are rows of their own that leave Claude neither tight nor maxed, and no reset is clipped where the usage grid turns to two columns. The lead row's only box toggles by a press and Space with focus kept on it, survives a lead change, is disabled under Mixed and moves no word of the row; a struck lead or box says why, and a lead whose lane on the box's side cannot run lands on its other. Models, from More with focus on its way back, shows the list as ladders whose rungs say their ids and prices only in the readout, walks them as one roving Tab stop, and offers one next action that follows the step: check models, the checking hold a double-click cannot pass, a charge approved only by one deliberate press, which records the approval, measuring per provider, checked with what it left out (Spark, by OMP's spark quota class, retired), back to code and, after ask first, check again, which Esc stops while its charge waits; a pasted list is refused until it is one, staged by one press (initializing an absent workspace once, never over a competing first save), discarded at its revision, or used by promoting exactly its review. A person's checks reach every workspace they open: after their first approve press records the approval and the press goes on to review and launch, a first check elsewhere spends within it without asking, and one that narrows the profile stops the press on what it changed, before any review, until Launch reviews and launches it; a workspace with the same account pool adopts their kept check with no inventory or spend, on a first Save & launch press that goes on to the launch and, once set up, on its own, Models saying it is checked and nothing reviewing the launch until it is pressed; a workspace never set up is never checked on its own; once the accounts change under a list that passed a check, Launch stays ready and launches while the check renewing it runs in the background within the approval; a renewal whose benchmark is refused is said in Models only, beside a launch still ready, and is never retried on its own; and Setup's ask first cancels a renewal spending under the approval at its owner, which writes nothing and starts nothing again. Setup is one checklist whose rows say what they need, pointing at the first unready one: another machine where OMP is absent, then each native review (Esc back onto the next action, nothing approved; sessions request both OMP's launch and its harness), OMP's folder job by either route, refused, and the owner's classifier reviewed by the real server before its configuration, refused; its facts say every check asks before it spends while nothing is approved. More and its Models item mark a staged list exactly when it waits beside the active one, and a staged-only workspace's launch opens Models on it. Profile and account drafts survive destinations, sheets, the accounts' management and a reload; own account edits never conflict with an unsaved edit while a foreign profile write does; a save is refused for a lead no account serves; a writer without a canvas saves, never reading launching…, and is told why launching waits, never read-only. From 170 to 1440px no view or sheet overflows, cuts or collides text, no route runs past the routing pane and More's menu stays inside the panel; pointing and focus shift nothing in a view or a sheet; More is a menu button by pointer and keyboard; keys stay panel-local and never act from a sheet or an open machine list, More menu or session drum; arrival keys reach the rows or take the launch's step; a refused keyboard step says why; the wheel steps only focused or rested rows; coarse targets are 44px in views and sheets, which hide their key line; reduced motion animates nothing and leaves no sheet duration. Nothing reviews a launch while the panel sits idle and its observations refresh: one press reviews the session and goes on to launch it, reading launching… throughout (saving… first for Save & launch) and Launch again after a refusal; a press whose own review shows an account pool the panel did not show stops on what will run, which stands while the panel reads again and, pointed, says where it runs, and the next press launches that review without another; skills, automation, refused launch, resume and session correlations (one row per folder with its drum, re-read on refresh) stay gated at their native owners, native permission and folder readiness stay review-only, and no native approval, terminal or provider request is made. A sponsor's one press has OMP review the session, then registers Code's Agent and creates its Run at the real doors; the machine's refused launchRun cancels the Run and the same press goes on to the reviewed session, as every later press there does and as a press does where OMP's harness is not enabled. Fixture Runs say their activity, a lease to 24 of 24, renewal due and detached, and one square on the Sessions tab; their dials go pending, queued as next, confirmed, clamped, not served, unanswered then sent again, and forbidden, and a starting or detached Run's dials send nothing; ←→ settle before one send and a press within that settle sends only itself, ↵ and Space send at once, ↑↓ cross Runs; send again keeps the said line whole on a coarse pointer; a pointed word, lease or activity moves nothing, reduced motion stills every mark, and from 170 to 1440px nothing overflows.`); } await run(); diff --git a/plugins/test/action-gate.test.ts b/plugins/test/action-gate.test.ts index ee8b482..bfc5efc 100644 --- a/plugins/test/action-gate.test.ts +++ b/plugins/test/action-gate.test.ts @@ -9,19 +9,19 @@ function checked(status: VerificationStatus, ready = true): GateFacts["verificat } /** A verified, saved, launchable workbench with a saved session chosen and nothing in flight; each test changes only what it is about. */ function facts(changes: Partial = {}, status: VerificationStatus = "current"): GateFacts { - return { configurationCurrent: true, unsaved: false, stale: false, writable: true, placeable: true, available: true, accounts: "usable", launchReady: true, previewCurrent: false, + return { configurationCurrent: true, unsaved: false, stale: false, writable: true, placeable: true, available: true, accounts: "usable", launchReady: true, profile: { source: "active" }, localDraft: null, record: { active: {} }, localReview: {}, skillProblems: [], queries: { setup: { error: null } }, verification: checked(status), running: false, charge: null, unservedLead: null, savedSessionId: "session-1", planYolo: false, ...changes }; } -const every: readonly WorkbenchIntent[] = ["verify", "confirm", "save", "review", "launch", "resume", "resume-with-team", "open", +const every: readonly WorkbenchIntent[] = ["verify", "confirm", "save", "launch", "resume", "resume-with-team", "open", "edit-team", "edit-options", "edit-machine", "edit-accounts"]; const claude = { provider: "anthropic", family: "anthropic", roles: ["reviewer", "security-reviewer"] }; const code = (verdict: GateVerdict) => verdict.open ? "open" : verdict.refusal.code; describe("one gate for every action", () => { test("nothing starts and nothing it depends on changes while a step runs", () => { - for (const intent of every) expect(code(actionGate(facts({ running: true, previewCurrent: true }), intent))).toBe("running"); + for (const intent of every) expect(code(actionGate(facts({ running: true }), intent))).toBe("running"); }); test("while a check's charge waits only its Approve proceeds; edits of the team, session options, machine and accounts wait with it", () => { @@ -31,9 +31,8 @@ describe("one gate for every action", () => { expect(code(actionGate(facts({}, "unverified"), "confirm"))).toBe("not-next"); }); - test("save, review and launch refuse a lead no included account serves, naming it, so a team the door refuses is never saved on the way", () => { - expect(actionGate(facts({ unservedLead: claude }), "review")).toEqual({ open: false, refusal: expect.objectContaining({ code: "no-account", gap: claude }) }); - expect(code(actionGate(facts({ unservedLead: claude, previewCurrent: true }), "launch"))).toBe("no-account"); + test("save and launch refuse a lead no included account serves, naming it, so a team the door refuses is never saved on the way", () => { + expect(actionGate(facts({ unservedLead: claude }), "launch")).toEqual({ open: false, refusal: expect.objectContaining({ code: "no-account", gap: claude }) }); expect(code(actionGate(facts({ unservedLead: claude, unsaved: true, localDraft: { source: "active" } }), "save"))).toBe("no-account"); expect(code(actionGate(facts({ unservedLead: claude }), "resume-with-team"))).toBe("no-account"); // Resuming saved state keeps the session's own models, so the team's lead is not its concern. @@ -41,14 +40,12 @@ describe("one gate for every action", () => { }); test("a chain asking again before its next step is stopped by a premise that changed while it ran", () => { - // Launch is the step only while the review is current; a changed input revokes it, and the gate says so. - expect(code(actionGate(facts({ previewCurrent: true }), "launch"))).toBe("open"); - expect(actionGate(facts({ previewCurrent: false }), "launch")).toEqual({ open: false, refusal: { code: "not-next", text: "Review before opening a terminal." } }); - expect(code(actionGate(facts({ previewCurrent: true, writable: false }), "launch"))).toBe("read-only"); - expect(code(actionGate(facts({ previewCurrent: true, available: false }), "launch"))).toBe("unavailable"); - // After a save, the review proceeds only for the saved team. - expect(code(actionGate(facts(), "review"))).toBe("open"); - expect(code(actionGate(facts({ unsaved: true, localDraft: { source: "active" } }), "review"))).toBe("not-next"); + // A saved team with every launch precondition is launched, its review inside the press; a changed premise stops it with its reason. + expect(code(actionGate(facts(), "launch"))).toBe("open"); + expect(code(actionGate(facts({ writable: false }), "launch"))).toBe("read-only"); + expect(code(actionGate(facts({ available: false }), "launch"))).toBe("unavailable"); + // A save goes on to the launch only for the saved team: an edit still unsaved is not launched. + expect(code(actionGate(facts({ unsaved: true, localDraft: { source: "active" } }), "launch"))).toBe("not-next"); }); test("verify is judged by its own preconditions, save by the save's", () => { @@ -113,8 +110,7 @@ describe("one gate for every action", () => { expect(code(actionGate(panelAlone({}, "unverified"), "verify"))).toBe("open"); expect(code(actionGate(panelAlone({ unsaved: true, localDraft: { source: "active" } }), "save"))).toBe("open"); expect(code(actionGate(panelAlone(), "edit-accounts"))).toBe("open"); - for (const intent of ["review", "resume", "resume-with-team"] as const) expect(code(actionGate(panelAlone(), intent))).toBe("placement"); - expect(code(actionGate(panelAlone({ previewCurrent: true }), "launch"))).toBe("placement"); + for (const intent of ["launch", "resume", "resume-with-team"] as const) expect(code(actionGate(panelAlone(), intent))).toBe("placement"); // A writer without a canvas is never told the workspace is read-only. for (const intent of every) expect(code(actionGate(panelAlone(), intent))).not.toBe("read-only"); }); diff --git a/plugins/test/agent-launch.test.ts b/plugins/test/agent-launch.test.ts index 4b6ae9f..f3b1b09 100644 --- a/plugins/test/agent-launch.test.ts +++ b/plugins/test/agent-launch.test.ts @@ -181,6 +181,39 @@ describe("the agent door sequence", () => { await expect(h.workflow.launchAgent(h.review)).rejects.toThrow(/could not be cancelled .*ends with its lease/); }); + test("a door Manifold refuses while OMP's isolate is busy validating is asked again, and the launch goes on", async () => { + const f = agentFixture(); + let refusals = 2; + f.answers["core.access.launchRun"] = () => refusals-- > 0 ? f.deny("core.access.launchRun", "exclusive request requires a drained guest") : launchedOf(); + await f.workflow.launchAgent(f.review); + expect(f.doors()).toEqual(["core.access.registerAgentV2", "core.access.createRunV2", "core.access.launchRun", "core.access.launchRun", "core.access.launchRun"]); + // Registration validates the same profile in the same isolate, so it is asked again the same way. + const g = agentFixture(); + let busy = 1; + const register = g.answers["core.access.registerAgentV2"]!; + g.answers["core.access.registerAgentV2"] = input => busy-- > 0 ? g.deny("core.access.registerAgentV2", "profile validation requires exclusive guest access") : register(input); + await g.workflow.launchAgent(g.review); + expect(g.doors()).toEqual(["core.access.registerAgentV2", "core.access.registerAgentV2", "core.access.createRunV2", "core.access.launchRun"]); + }); + + test("a busy refusal that outlasts the retries is said in words and its Run cancelled; no other refusal or door is asked again", async () => { + const f = agentFixture(); + f.answers["core.access.launchRun"] = () => f.deny("core.access.launchRun", "exclusive request requires a drained guest"); + const refused = await f.workflow.launchAgent(f.review).then(() => null, (error: unknown) => error); + expect(failureWords((refused as Error).message)).toBe("OMP was busy for a moment. Try again."); + expect(f.doors().filter(door => door === "core.access.launchRun")).toHaveLength(5); + expect(f.calls.at(-1)).toEqual({ door: "core.access.finishAgentRunV2", input: { runId: "run-1", outcome: "cancelled" } }); + const g = agentFixture(); + g.answers["core.access.launchRun"] = () => g.deny("core.access.launchRun", "run_launch_owner_unavailable"); + await expect(g.workflow.launchAgent(g.review)).rejects.toThrow("run_launch_owner_unavailable"); + expect(g.doors()).toEqual(["core.access.registerAgentV2", "core.access.createRunV2", "core.access.launchRun", "core.access.finishAgentRunV2"]); + // A door that validates nothing is never asked again, busy or not: only Manifold's profile validation refuses that way before acting. + const h = agentFixture(); + h.answers["core.access.createRunV2"] = () => h.deny("core.access.createRunV2", "exclusive request requires a drained guest"); + await expect(h.workflow.launchAgent(h.review)).rejects.toThrow("exclusive request requires a drained guest"); + expect(h.doors()).toEqual(["core.access.registerAgentV2", "core.access.createRunV2"]); + }); + test("automatic plans, session options, a missing harness or a caller who cannot sponsor keep the launch a reviewed session", async () => { const f = agentFixture(); expect(agentLaunchBlocker(f.review, true, false, true)).toBeNull(); diff --git a/plugins/test/statement-model.test.ts b/plugins/test/statement-model.test.ts index 9dcbbfb..997a7b8 100644 --- a/plugins/test/statement-model.test.ts +++ b/plugins/test/statement-model.test.ts @@ -10,7 +10,7 @@ import { projectUsage } from "../domain/usage.ts"; import { optionConsequence, rescue } from "../code/generator/consequences.ts"; import type { GateRefusal, LaunchStep } from "../code/generator/launch-step.ts"; import { - causalRedline, commitKind, estimateReadouts, fixView, grounded, launchLine, launchReadout, machineSlot, movesText, projectionOf, reviewMatches, statementSlots, verbView, + causalRedline, commitKind, estimateReadouts, fixView, launchLine, launchReadout, machineSlot, movesText, projectionOf, reviewDifferences, reviewMatches, statementSlots, verbView, type SlotOption, type StatementContext, type StatusFacts, type StatusLine, type VerbFacts, type Vocabulary, } from "../code/generator/statement-model.ts"; @@ -143,18 +143,18 @@ describe("the verb names the model's step", () => { const facts = (step: LaunchStep["step"], changes: Partial = {}): VerbFacts => ({ step: step === "blocked" ? { step, reason: { code: "read-only", text: "Edit access needed." } } : { step, reason: null }, verdict: open, busy: false, inFlight: null, chaining: false, unsaved: false, draft: false, edited: false, phase: null, mode: null, serves: true, - stranded: false, grounded: true, placeable: true, launching: false, ...changes, + stranded: false, placeable: true, launching: false, ...changes, }); - test("an edited team saves first, and continues to the launch only when the readings are present", () => { - expect(verbView(facts("save", { unsaved: true, draft: true }))).toMatchObject({ label: "Save & launch", state: "ready", saves: true, launches: true }); - expect(verbView(facts("save", { unsaved: true, draft: true, grounded: false }))).toMatchObject({ label: "Save & review", saves: true, launches: false }); + test("an edited team saves first and goes on to the launch; no label names a review", () => { + expect(verbView(facts("save", { unsaved: true, draft: true }))).toMatchObject({ label: "Save & launch", state: "ready", saves: true }); + expect(verbView(facts("launch"))).toMatchObject({ label: "Launch", state: "ready", saves: false }); }); test("a team with a role that has no route launches anyway; the gate, not quota, decides whether it may", () => { expect(verbView(facts("launch", { stranded: true }))).toMatchObject({ label: "Launch anyway", state: "ready" }); const refusal: GateRefusal = { code: "no-account", text: "No included account serves a provider this team leads on." }; - expect(verbView(facts("review", { verdict: { open: false, refusal } }))).toMatchObject({ label: "Review", state: "refused", refusal }); + expect(verbView(facts("launch", { stranded: true, verdict: { open: false, refusal } }))).toMatchObject({ label: "Launch", state: "refused", refusal }); }); test("Launch anyway is never the label of a save the door refuses for its lead", () => { @@ -165,7 +165,7 @@ describe("the verb names the model's step", () => { }); test("with no canvas to place a terminal, an edited team only saves; a staged model list is opened, not refused", () => { - expect(verbView(facts("save", { unsaved: true, draft: true, placeable: false }))).toMatchObject({ label: "Save", state: "ready", saves: true, launches: false }); + expect(verbView(facts("save", { unsaved: true, draft: true, placeable: false }))).toMatchObject({ label: "Save", state: "ready", saves: true }); const staged = { step: "blocked", reason: { code: "staged", text: "A staged model list waits in Models." } } as const; expect(verbView(facts("blocked", { step: staged, verdict: { open: false, refusal: staged.reason } }))).toMatchObject({ state: "ready", opens: "models" }); }); @@ -188,13 +188,13 @@ describe("the verb names the model's step", () => { .toMatchObject({ label: "Checking models…", state: "busy" }); }); - test("while a step runs the verb names the step", () => { + test("while a step runs the verb names the step, and a save on its way to the launch is already launching", () => { expect(verbView(facts("launch", { busy: true, inFlight: "launch" }))).toMatchObject({ label: "Launching…", state: "busy" }); - expect(verbView(facts("save", { chaining: true }))).toMatchObject({ label: "Reviewing…", state: "busy" }); + expect(verbView(facts("save", { chaining: true }))).toMatchObject({ label: "Launching…", state: "busy" }); }); }); -describe("Save & launch stops at a review that differs from the projection", () => { +describe("a Launch press stops on a review unlike what the panel showed", () => { const shown = review({ lane: gptLed }); const pool = { "openai-codex": [{}], anthropic: [{}] }; @@ -212,10 +212,23 @@ describe("Save & launch stops at a review that differs from the projection", () expect(movesText(catalog, shown, shown)).toBe(""); }); - test("a projection rests on present readings only when every lead's pool is judged fresh and the pool is known", () => { - expect(grounded(catalog, shown.routes, pools(), new Set(["openai-codex", "anthropic"]))).toBe(true); - expect(grounded(catalog, shown.routes, pools({ stale: true }), new Set(["openai-codex", "anthropic"]))).toBe(false); - expect(grounded(catalog, shown.routes, pools(), null)).toBe(false); + test("a pool that moved is said as the families that joined or left it, or that the accounts were not known", () => { + const reviewed = { review: shown, accountPool: pool }; + expect(reviewDifferences(catalog, shown, ["openai-codex"], reviewed, vocab)).toEqual(["Claude accounts join the pool"]); + expect(reviewDifferences(catalog, shown, ["openai-codex", "anthropic", "deepseek"], reviewed, vocab)).toEqual(["no deepseek account in the pool"]); + expect(reviewDifferences(catalog, shown, null, reviewed, vocab)).toEqual(["the accounts were not known before"]); + }); + + test("the line says what will run differs and both ways on, never as a failure", () => { + const ready = verbView({ step: { step: "launch", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, + draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, placeable: true, launching: false }); + const line = launchLine({ verb: ready, phase: null, mode: null, progress: null, charge: null, keepsChecks: true, inFlight: null, busy: false, chaining: false, + launching: false, machine: { name: "Studio", online: true, revoked: false }, machineChosen: true, rosterUnread: false, otherMachine: null, message: null, + outcome: null, stop: null, fix: null, differs: ["Claude accounts join the pool"], narrowed: null, laneFix: null, nobodyServes: false, listFailure: "none", + ompMissing: false, accountsProblem: null, configurationFailed: false }, vocab)!; + expect(line.parts).toEqual([{ text: "What will run differs", tone: "warn" }, { text: "Claude accounts join the pool", tone: "plain" }, + { text: "launch again to run it, or change a setting first", tone: "meta" }]); + expect(line.actions).toEqual([]); }); }); @@ -240,7 +253,7 @@ describe("the fix for roles with no route", () => { describe("the launch line, in precedence", () => { const charge = { requests: 19, providers: [{ provider: "openai-codex", requests: 12 }, { provider: "anthropic", requests: 7 }] }; const ready = verbView({ step: { step: "launch", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false }); + draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, placeable: true, launching: false }); const facts = (changes: Partial = {}): StatusFacts => ({ verb: ready, phase: null, mode: null, progress: null, charge: null, keepsChecks: true, inFlight: null, busy: false, chaining: false, launching: false, machine: { name: "Studio", online: true, revoked: false }, machineChosen: true, rosterUnread: false, otherMachine: null, message: null, outcome: null, @@ -254,7 +267,7 @@ describe("the launch line, in precedence", () => { test("at rest it says nothing: the label, the routing and the usage say the rest", () => { expect(launchLine(facts(), vocab)).toBeNull(); const verify = verbView({ step: { step: "verify", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, edited: false, phase: null, mode: null, serves: false, stranded: false, grounded: true, placeable: true, launching: false }); + draft: false, edited: false, phase: null, mode: null, serves: false, stranded: false, placeable: true, launching: false }); expect(launchLine(facts({ verb: verify }), vocab)).toBeNull(); // A check the panel runs beside a launch the list in use serves is said only while the launch is pointed. const beside = launchLine(facts({ phase: "benchmark", mode: "background", progress: { done: 3, total: 19 } }), vocab); @@ -377,29 +390,27 @@ describe("the launch line, in precedence", () => { describe("what the launch says while it is pointed", () => { const verb = (changes: Partial = {}) => verbView({ step: { step: "save", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, - unsaved: true, draft: true, edited: true, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false, ...changes }); + unsaved: true, draft: true, edited: true, phase: null, mode: null, serves: true, stranded: false, placeable: true, launching: false, ...changes }); test("a press that saves says what it changes on the workspace profile; a refused one says only its reason", () => { const edits = [{ word: "thinking" as const, from: "medium", to: "high" }]; - expect(launchReadout({ verb: verb(), edits, reviewed: null, dials: null, grounded: true, launchStatus: "Ready." }, vocab)) + expect(launchReadout({ verb: verb(), edits, reviewed: null, dials: null, launchStatus: "Ready." }, vocab)) .toEqual({ text: "1 change to the profile: thinking medium → high", warn: false }); const refusal: GateRefusal = { code: "read-only", text: "Edit access needed." }; - expect(launchReadout({ verb: verb({ verdict: { open: false, refusal } }), edits, reviewed: null, dials: null, grounded: true, launchStatus: "Ready." }, vocab)) + expect(launchReadout({ verb: verb({ verdict: { open: false, refusal } }), edits, reviewed: null, dials: null, launchStatus: "Ready." }, vocab)) .toEqual({ text: "Edit access needed.", warn: true }); }); - test("a reviewed launch names the pool it reviewed; an unreviewed one on readings not current says the review shows it", () => { + test("a held review names what the next press runs and where; with none held the launch says its status", () => { const launch = verbView({ step: { step: "launch", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false }); - expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [{ family: "openai", count: 2 }] }, dials: null, grounded: true, launchStatus: "Ready." }, vocab).text) - .toBe("reviewed on Studio: GPT 2"); + draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, placeable: true, launching: false }); + expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [{ family: "openai", count: 2 }] }, dials: null, launchStatus: "Ready." }, vocab).text) + .toBe("runs on Studio: GPT 2"); // Pointed, the launch says whether it runs with live dials, or why not. - expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [] }, dials: "run", grounded: true, launchStatus: "Ready." }, vocab).text) - .toBe("reviewed on Studio · live dials in Sessions"); - expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [] }, dials: "options", grounded: true, launchStatus: "Ready." }, vocab).text) - .toBe("reviewed on Studio · no live dials: session options are set"); - const review = verbView({ step: { step: "review", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: false, placeable: true, launching: false }); - expect(launchReadout({ verb: review, edits: [], reviewed: null, dials: null, grounded: false, launchStatus: "Ready." }, vocab).text).toBe("the review shows the pool before anything runs"); + expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [] }, dials: "run", launchStatus: "Ready." }, vocab).text) + .toBe("runs on Studio · live dials in Sessions"); + expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [] }, dials: "options", launchStatus: "Ready." }, vocab).text) + .toBe("runs on Studio · no live dials: session options are set"); + expect(launchReadout({ verb: launch, edits: [], reviewed: null, dials: null, launchStatus: "Ready to open a terminal." }, vocab).text).toBe("Ready to open a terminal."); }); }); diff --git a/plugins/test/verification.test.ts b/plugins/test/verification.test.ts index 3e093fd..5dcf76f 100644 --- a/plugins/test/verification.test.ts +++ b/plugins/test/verification.test.ts @@ -4,8 +4,8 @@ import type { VerificationProvenance } from "../code/contract.ts"; import { operationReady } from "../code/permission-plan.ts"; import type { Selection } from "../domain/contracts.ts"; import { editedDraft } from "../code/generator/draft-store.ts"; -import { autoReviewDue, draftStale, editHoldsCatalogWrite, followCatalogWrite, followInitialization, followRecord, launchStatusText, nextLaunchStep, - type CatalogWrite, type DraftBase, type GateFacts, type LaunchFacts, type SharedBase } from "../code/generator/launch-step.ts"; +import { draftStale, editHoldsCatalogWrite, followCatalogWrite, followInitialization, followRecord, launchStatusText, nextLaunchStep, + type CatalogWrite, type DraftBase, type LaunchFacts, type SharedBase } from "../code/generator/launch-step.ts"; import type { ProfileDraft } from "../code/generator/workbench-model.ts"; import { adoptsOnly, backgroundCheckDue, CHECKING_HOLD_MS, confirmsCharge, ownInitialization, spendsWithoutAsking, verificationState, type BackgroundFacts, type VerificationStatus } from "../code/generator/verification.ts"; @@ -30,8 +30,7 @@ describe("whether a recorded verification still holds", () => { // The runtime is upgraded on the destination with the same model catalog and the same accounts. const upgraded = verificationState(provenance, { ...present, inventoryArtifactSha256: "9".repeat(64), pool: same }, false); expect(upgraded).toMatchObject({ status: "current", changes: { runtimeChanged: true, catalogChanged: false, unpublished: [] } }); - expect(nextLaunchStep(facts({}, upgraded.status)).step).toBe("review"); - expect(nextLaunchStep(facts({ previewCurrent: true }, upgraded.status)).step).toBe("launch"); + expect(nextLaunchStep(facts({}, upgraded.status)).step).toBe("launch"); // A model catalog republished under another revision that still lists every routed model. const republished = verificationState(provenance, { ...present, catalogRevision: moved, pool: same }, false); expect(republished).toMatchObject({ status: "current", changes: { runtimeChanged: false, catalogChanged: true } }); @@ -77,14 +76,14 @@ describe("whether a recorded verification still holds", () => { */ function facts(changes: Partial = {}, status: VerificationStatus = "current", ready = true): LaunchFacts { const held = status === "verifying" ? "unverified" : status; - return { configurationCurrent: true, unsaved: false, stale: false, writable: true, placeable: true, available: true, accounts: "usable", launchReady: true, previewCurrent: false, + return { configurationCurrent: true, unsaved: false, stale: false, writable: true, placeable: true, available: true, accounts: "usable", launchReady: true, profile: { source: "active" }, localDraft: null, record: { active: {} }, localReview: {}, skillProblems: [], queries: { setup: { error: null } }, verification: { status, held, passed: held !== "unverified", mode: null, ready }, unservedLead: null, ...changes }; } const claude = { provider: "anthropic", family: "anthropic", roles: ["reviewer"] }; describe("the next launch step with the model check", () => { - test("a check precedes save and review whenever the list in use cannot serve a launch, and the Launch press runs it", () => { + test("a check precedes save and launch whenever the list in use cannot serve a launch, and the Launch press runs it", () => { for (const status of ["unverified", "unpublished"] as const) { expect(nextLaunchStep(facts({}, status)).step).toBe("verify"); // The bundled preview and an unsaved edit both check before anything saves. @@ -92,16 +91,15 @@ describe("the next launch step with the model check", () => { expect(nextLaunchStep(facts({ unsaved: true, localDraft: { source: "active" } }, status)).step).toBe("verify"); } expect(launchStatusText(facts({}, "unverified"))).toBe("Launch first checks which models your accounts can use."); - expect(nextLaunchStep(facts()).step).toBe("review"); - expect(nextLaunchStep(facts({ previewCurrent: true })).step).toBe("launch"); + expect(nextLaunchStep(facts()).step).toBe("launch"); + expect(launchStatusText(facts())).toBe("Ready to open a terminal."); expect(nextLaunchStep(facts({ unsaved: true, localDraft: { source: "active" } })).step).toBe("save"); // A bundled preview is never saved directly, even when a check is somehow current. expect(nextLaunchStep(facts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" } }))).toMatchObject({ step: "blocked", reason: { code: "unsaved" } }); }); test("a list that passed a check keeps serving while only the accounts changed, unless the door would refuse a lead", () => { - expect(nextLaunchStep(facts({}, "accounts-changed")).step).toBe("review"); - expect(nextLaunchStep(facts({ previewCurrent: true }, "accounts-changed")).step).toBe("launch"); + expect(nextLaunchStep(facts({}, "accounts-changed")).step).toBe("launch"); expect(nextLaunchStep(facts({ unsaved: true, localDraft: { source: "active" } }, "accounts-changed")).step).toBe("save"); // A lead the changed accounts no longer serve is refused by the session door, so the press checks first. expect(nextLaunchStep(facts({ unservedLead: claude }, "accounts-changed")).step).toBe("verify"); @@ -109,7 +107,7 @@ describe("the next launch step with the model check", () => { // A list that never passed one (imported) has nothing to keep serving with. expect(nextLaunchStep(facts({ verification: { status: "accounts-changed", held: "accounts-changed", passed: false, mode: null, ready: true } })).step).toBe("verify"); // A check running beside it, the panel's own, holds nothing up. - expect(nextLaunchStep(facts({ previewCurrent: true, verification: { status: "verifying", held: "accounts-changed", passed: true, mode: "background", ready: true } })).step).toBe("launch"); + expect(nextLaunchStep(facts({ verification: { status: "verifying", held: "accounts-changed", passed: true, mode: "background", ready: true } })).step).toBe("launch"); }); test("configuration and a staged catalog still come first, and the check has its own preconditions in order", () => { @@ -133,7 +131,7 @@ describe("the next launch step with the model check", () => { expect(nextLaunchStep(unseated)).toMatchObject({ step: "blocked", reason: { code: "staged", action: "models" } }); expect(launchStatusText(unseated)).toBe(nextLaunchStep(unseated).reason!.text); // Beside an active catalog a staged one changes nothing: the active profile verifies and launches as before. - expect(nextLaunchStep(facts({ record: { active: {}, draft: {} } })).step).toBe("review"); + expect(nextLaunchStep(facts({ record: { active: {}, draft: {} } })).step).toBe("launch"); }); }); @@ -338,32 +336,3 @@ describe("checks the panel runs or spends on its own", () => { expect(due({ approvedRequests: 0, adoptable: true })).toBe(true); }); }); - -/** The same workbench, as the gate reads it: nothing in flight, no charge, every lead served. */ -function gateFacts(changes: Partial = {}, status: VerificationStatus = "current"): GateFacts { - return { ...facts({}, status), running: false, charge: null, unservedLead: null, savedSessionId: "", planYolo: false, ...changes }; -} - -describe("reviewing the saved team when its inputs settle", () => { - test("only the saved team on a list that serves a launch, with every launch precondition, reviews by itself", () => { - expect(autoReviewDue(gateFacts(), 4, null)).toBe(true); - expect(autoReviewDue(gateFacts({ unsaved: true, localDraft: { source: "active" } }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" }, record: null }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ profile: { source: "draft" }, record: { draft: {} } }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({}, "accounts-changed"), 4, null)).toBe(true); - expect(autoReviewDue(gateFacts({}, "unpublished"), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ writable: false }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ launchReady: false }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ previewCurrent: true }), 4, null)).toBe(false); - }); - - test("never while a step runs or a charge waits or a lead is unserved, and once per scope until an input changes", () => { - expect(autoReviewDue(gateFacts({ running: true }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ charge: { requests: 3 } }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({ unservedLead: { provider: "anthropic", family: "anthropic", roles: ["reviewer"] } }), 4, null)).toBe(false); - // A refused review or launch in this scope waits for an explicit Review... - expect(autoReviewDue(gateFacts(), 4, 4)).toBe(false); - // ...and a changed input opens a new scope, which reviews again. - expect(autoReviewDue(gateFacts(), 5, 4)).toBe(true); - }); -});