diff --git a/packages/amico-run/src/authoring.ts b/packages/amico-run/src/authoring.ts index 788046eb..f160a6d1 100644 --- a/packages/amico-run/src/authoring.ts +++ b/packages/amico-run/src/authoring.ts @@ -20,6 +20,12 @@ export interface AuthoringConfig { exemplars?: string; // abs path to exemplars/index.json verify_harness?: string; // abs path to julia/verify_rollout.jl verify_tolerance: number; // tier-3 re-rollout agreement (absolute) + /** Absolute path to the session's STAGED solve template — the copy whose SOLVER + * is already substituted from the selected tier. `resolve` returns this as + * template_path when present, so the vetted workflow AGENTS.md documents + * ("copy template_path") lands on the same file the preamble points at. + * Absent for a bare dev invocation, which falls back to the registry path. */ + staged_template?: string; } export const DEFAULT_ALLOWLIST = ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"]; @@ -62,6 +68,7 @@ export function readAuthoring(): { config: AuthoringConfig; warning?: string } { exemplars: typeof data.exemplars === "string" ? data.exemplars : undefined, verify_harness: typeof data.verify_harness === "string" ? data.verify_harness : undefined, verify_tolerance: typeof data.verify_tolerance === "number" ? data.verify_tolerance : DEFAULT_TOLERANCE, + staged_template: typeof data.staged_template === "string" ? data.staged_template : undefined, }, }; } diff --git a/packages/amico-run/src/launch.ts b/packages/amico-run/src/launch.ts index dd118c86..f8181384 100644 --- a/packages/amico-run/src/launch.ts +++ b/packages/amico-run/src/launch.ts @@ -13,7 +13,8 @@ import { RemoteExecutor } from "./remote_executor.js"; import { ConfigError, type Executor, type Finished, type SubmitOpts } from "./types.js"; import { readAuthoring } from "./authoring.js"; import { runGate } from "./gate.js"; -import { readSolverMode } from "./solver_mode.js"; +import { hpTierSelected } from "./solver_mode.js"; +import { hasCloudConfig } from "./remote_config.js"; import { assembleWarrantContext } from "./warrant_context.js"; import { runVerification } from "./verify.js"; import { trySubcommand } from "./subcommands.js"; @@ -43,6 +44,7 @@ export async function launch(argv: string[]): Promise { let specPath: string | undefined; const opts: SubmitOpts = { julia: {} }; let projectExplicit = false; + let executorExplicit = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; @@ -59,6 +61,7 @@ export async function launch(argv: string[]): Promise { return 0; case "--executor": executor = next(); + executorExplicit = true; break; case "--lab": opts.lab = next(); @@ -126,25 +129,52 @@ export async function launch(argv: string[]): Promise { console.error(`amico-run: unknown --executor ${executor} (supported: local, remote)`); return 64; } - // Piccolissimo + Altissimo is a CLOUD-ONLY tier, so a LOCAL launch is refused - // while it is the selected solver. This lives here, not in runGate, because - // the gate only sees --spec runs (see `if (specPath)` below) — this line is - // the one choke point EVERY run passes through, spec or not. + // Piccolissimo + Altissimo is a CLOUD-ONLY tier. This lives here, not in + // runGate, because the gate only sees --spec runs (see `if (specPath)` below) — + // this is the one choke point EVERY run passes through, spec or not. // - // Why it is needed at all: selecting HP grants the `issimo` entitlement, so - // the import scan would happily admit a local `using Piccolissimo` and the - // solve would precompile the HP stack (and IPOPT) on the laptop — the exact - // failure this tier exists to avoid, and what amico-run's process-group - // timeout used to SIGTERM mid-precompile. + // Why it exists at all: selecting HP grants the `issimo` entitlement, so the + // import scan would happily admit a local `using Piccolissimo` and the solve + // would precompile the HP stack (and IPOPT) on the laptop — the exact failure + // this tier exists to avoid, and what amico-run's process-group timeout used to + // SIGTERM mid-precompile. // - // Fails SAFE: an absent or corrupt solver-mode.json reads as piccolo, so + // Two different situations, deliberately handled differently: + // - executor defaulted (no --executor at all): PROMOTE to remote. Selecting + // the cloud tier IS the routing decision; making the caller restate it as a + // flag only creates a way to get it wrong. Previously this returned 64 and + // relied on the agent reading the message and retrying — a round-trip that + // surfaced to users as a failed run. + // - --executor local passed EXPLICITLY: refuse. That is a direct + // contradiction of the selected tier, and silently inverting an explicit + // flag is worse than an error. + // + // Fails SAFE either way: with neither signal present this reads as piccolo, so // ordinary local runs behave exactly as before. - if (executor === "local" && readSolverMode() === "hp") { + if (executor === "local" && hpTierSelected()) { + if (executorExplicit) { + console.error( + `amico-run: Piccolissimo + Altissimo runs in Harmoniqs Cloud and never solves locally — this launch is --executor local. ` + + `Drop the flag (cloud is automatic for this tier), or switch the solver to Piccolo (the model · solver control) for local solves.`, + ); + return 64; + } + // Do not promote into a broken remote. Without a connection the submit would + // fail deep in RemoteExecutor with `cloud config not found: ~/.amico/cloud.json` + // — accurate, but it names a file the user has never heard of instead of the + // control they need. Promotion made this path reachable by DEFAULT, so it has + // to say the human thing. + if (!hasCloudConfig()) { + console.error( + `amico-run: Piccolissimo + Altissimo runs in Harmoniqs Cloud, but no cloud connection is configured. ` + + `Connect Harmoniqs Cloud in the Connections panel (paste your API key), or switch the solver to Piccolo for local solves.`, + ); + return 64; + } + executor = "remote"; console.error( - `amico-run: Piccolissimo + Altissimo runs in Harmoniqs Cloud and never solves locally — this launch is --executor local. ` + - `Run it with --executor remote, or switch the solver to Piccolo (the model · solver control) for local solves.`, + `amico-run: solver is Piccolissimo + Altissimo → running in Harmoniqs Cloud (--executor remote, automatic for this tier)`, ); - return 64; } // --spec + --executor remote is SUPPORTED (High-Performance + Cloud, tier=hpc): // the launch gate is a static local check and runs identically for both @@ -155,6 +185,44 @@ export async function launch(argv: string[]): Promise { console.error(`amico-run: --julia/--project/--sysimage are ignored with --executor remote (the runner image owns the environment)`); } + // ── cloud telemetry preflight ── + // A cloud script that never writes run.log CANNOT stream anything to the Run + // Inspector, and the user pays the full queue + instance-boot wait to find out. + // + // Why run.log specifically: /solves/{id}/stats greps AMICODE_ITER out of the + // run.log the sidecar syncs from the working directory. Printing to stdout is + // NOT enough — the runner's stdout goes to the SSM command stream, which no API + // exposes. The bundled template handles this (its emit() appends to run.log + // whenever TASK_ID is set, and the runner sets TASK_ID), so a script copied from + // the template passes this check for free. + // + // This is a refusal rather than a warning because it is a certainty, not a risk: + // three cloud runs in two days produced an empty Inspector this way, every one of + // them a hand-authored script (two with invented API that also died at load). + // Warnings on stderr have not changed that. Failing here costs a second and names + // the fix; failing in the cloud costs ten minutes and names nothing. + // Gated on hasCloudConfig() so error PRECEDENCE is preserved: a remote launch + // with no connection at all should still report the missing connection, which is + // the more fundamental problem, rather than being pre-empted by a telemetry + // complaint about a script that was never going to run. + if (executor === "remote" && script && hasCloudConfig()) { + let text = ""; + try { + text = readFileSync(script, "utf8"); + } catch { + /* unreadable script — the executor reports that with its own message */ + } + if (text !== "" && !text.includes("run.log")) { + console.error( + `amico-run: this script cannot stream telemetry from Harmoniqs Cloud — it never writes run.log, so the Run Inspector would stay empty for the whole solve.\n` + + ` The cloud reads iterations by grepping AMICODE_ITER out of run.log in the run directory; stdout does not reach it.\n` + + ` Fix: author from the bundled solve template (the path AGENTS.md gives you) instead of writing the script from scratch — its emit() writes run.log already.\n` + + ` If you are deliberately hand-rolling, append each AMICODE_ITER line to "run.log" in the working directory as well as printing it.`, + ); + return 64; + } + } + // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── if (specPath) { // specRaw was parsed above (problem_spec detection). Only read the script for diff --git a/packages/amico-run/src/solver_mode.ts b/packages/amico-run/src/solver_mode.ts index 1c44eb8a..4617d565 100644 --- a/packages/amico-run/src/solver_mode.ts +++ b/packages/amico-run/src/solver_mode.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { readAuthoring } from "./authoring.js"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -41,3 +42,30 @@ export function readSolverMode(env: NodeJS.ProcessEnv = process.env): SolverMode return "piccolo"; } } + +/** Is the Piccolissimo + Altissimo tier selected, by EITHER of the two signals + * that record it? + * + * solver-mode.json alone is not enough. It only changes when something posts a + * `status:"switching"` request, so a dropped write leaves it stale — observed + * 2026-08-05, where it read `piccolo` (dated Jul 28) while the entitlement was + * granted and Harmoniqs Cloud was connected. Routing keyed off that file alone, + * so a paid-tier solve ran locally on IPOPT and no surface anywhere said so. + * + * The second signal is the entitlement-resolved allowlist in authoring.json, + * which session prep writes from the SAME switch and which amico-run already + * reads for the gate. Piccolissimo in the allowlist means the issimo entitlement + * is granted, and switching back to Piccolo revokes it — so it is self-cleaning + * rather than a second thing to keep in sync. + * + * OR, not AND, deliberately: of the two ways to be wrong, silently charging + * someone for the cloud tier and giving them a local IPOPT solve is much worse + * than telling a revoked user to reconnect. Still fails SAFE overall — with + * neither signal present this is false and local runs behave exactly as before. */ +export function hpTierSelected( + env: NodeJS.ProcessEnv = process.env, + allowlist?: readonly string[], +): boolean { + if (readSolverMode(env) === "hp") return true; + return (allowlist ?? readAuthoring().config.allowlist).includes("Piccolissimo"); +} diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts index c6027b1c..8202b0dc 100644 --- a/packages/amico-run/src/subcommands.ts +++ b/packages/amico-run/src/subcommands.ts @@ -49,7 +49,20 @@ export function resolveCommand(argv: string[]): number { const out: Record = { tier: match.tier }; if (match.template) { out.source = { template_id: match.template.id }; - out.template_path = resolve(registryDir, match.template.path); + // Prefer the session's STAGED template. The registry path is the BUNDLED file, + // whose {{SOLVER}} placeholder is unsubstituted and degrades to ipopt — so an + // HP user following the documented vetted workflow ("copy template_path") got + // a Piccolo/IPOPT script no matter what the solver toggle said. The staged copy + // is the same template with SOLVER already resolved from the selected tier, and + // it is the file AGENTS.md's preamble points at, so the two instructions now + // agree instead of contradicting each other. + // + // Falls back to the registry path when no session staged one (a bare dev + // invocation), which is the historical behaviour. + out.template_path = + config.staged_template && existsSync(config.staged_template) + ? config.staged_template + : resolve(registryDir, match.template.path); out.packages = match.template.packages; } else if (match.exemplar) { out.source = { exemplar_id: match.exemplar.id }; diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index dfeef6c1..9e55d844 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -5,6 +5,11 @@ import { join } from "node:path"; import { fakeJulia, hermeticOpsEnv, readToml, tmpRoot } from "./helpers.js"; import { FakeCloud } from "./fake_cloud.js"; +/** Stand-in for a template-derived cloud script: it appends telemetry to + * run.log, which is what the cloud actually greps and what the telemetry + * preflight requires. A bare stub would be refused, correctly. */ +const REMOTE_STUB = 'open("run.log", "a") do io\n println(io, "AMICODE_ITER iter=1 f=1e-2")\nend\n'; + const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); @@ -27,7 +32,7 @@ describe("amico-run CLI", () => { it("clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0", () => { const root = tmpRoot(); const julia = fakeJulia(root, "j", `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia]); expect(r.code).toBe(0); expect(r.stdout).toContain("AMICODE_ITER iter=1 f=0.5"); @@ -36,7 +41,7 @@ describe("amico-run CLI", () => { it("julia rc 7 passes through as exit 7", () => { const root = tmpRoot(); const r = run([ - fakeJulia(root, "s.jl", ""), + fakeJulia(root, "s.jl", REMOTE_STUB), "--runs-root", join(root, "runs"), "--julia", @@ -53,13 +58,13 @@ describe("amico-run CLI", () => { }); it("unknown flag → 64 (never silently swallowed, spec Q68)", () => { const root = tmpRoot(); - const r = run([fakeJulia(root, "s.jl", ""), "--gates", "X"]); + const r = run([fakeJulia(root, "s.jl", REMOTE_STUB), "--gates", "X"]); expect(r.code).toBe(64); expect(r.stderr).toMatch(/unknown flag/); }); it("--executor remote without cloud config → 64 (config-class), no run dir", () => { const root = tmpRoot(); - const r = run([fakeJulia(root, "s.jl", ""), "--executor", "remote", "--runs-root", join(root, "runs")], { + const r = run([fakeJulia(root, "s.jl", REMOTE_STUB), "--executor", "remote", "--runs-root", join(root, "runs")], { AMICO_CLOUD_FILE: join(root, "no-such-cloud.json"), // hermetic: ignore any real ~/.amico/cloud.json AMICO_CLOUD_URL: "", AMICO_CLOUD_TOKEN: "", @@ -71,7 +76,7 @@ describe("amico-run CLI", () => { it("--executor bogus → 64 naming the supported set", () => { const root = tmpRoot(); - const r = run([fakeJulia(root, "s.jl", ""), "--executor", "bogus"]); + const r = run([fakeJulia(root, "s.jl", REMOTE_STUB), "--executor", "bogus"]); expect(r.code).toBe(64); expect(r.stderr).toMatch(/local, remote/); }); @@ -83,7 +88,7 @@ describe("amico-run CLI", () => { const root = tmpRoot(); writeFileSync(join(root, "spec.json"), "{}"); const r = run( - [fakeJulia(root, "s.jl", ""), "--executor", "remote", "--spec", join(root, "spec.json")], + [fakeJulia(root, "s.jl", REMOTE_STUB), "--executor", "remote", "--spec", join(root, "spec.json")], { AMICO_CLOUD_URL: "http://127.0.0.1:1", AMICO_CLOUD_TOKEN: "t" }, ); expect(r.code).toBe(64); @@ -102,7 +107,7 @@ describe("amico-run CLI", () => { }; try { const root = tmpRoot(); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); // ASYNC lane (the SIGTERM-test pattern, cli.test.ts:191-196) — NEVER the // sync run() helper here: FakeCloud runs IN this test process, and // execFileSync would block the event loop, so the fake could never answer @@ -128,7 +133,7 @@ describe("amico-run CLI", () => { }, 15000); it("--spec: gate failure → 64, one-line stderr reason, NO run dir (spec C)", () => { const root = tmpRoot(); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); writeFileSync(join(root, "bad.json"), JSON.stringify({ nope: true })); const r = run([ script, @@ -145,7 +150,7 @@ describe("amico-run CLI", () => { }); it("--spec pass: solvespec.json persisted canonical + run.toml v2 stamped (spec C)", () => { const root = tmpRoot(); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const spec = { schema_version: "2", script_path: script, @@ -178,7 +183,7 @@ describe("amico-run CLI", () => { }); it("--spec env.kind=project sets the julia --project arg from env.project (spec C)", () => { const root = tmpRoot(); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const env = join(root, "env"); mkdirSync(env, { recursive: true }); writeFileSync(join(env, "Project.toml"), `[deps]\n`); @@ -198,7 +203,7 @@ describe("amico-run CLI", () => { }); it("--spec tier=free: verification runs after FINISHED (AMICODE_VERIFIED + verification.toml); vetted: neither (spec C)", () => { const root = tmpRoot(); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const env = join(root, "env"); mkdirSync(env, { recursive: true }); writeFileSync(join(env, "Project.toml"), `[deps]\n`); @@ -262,7 +267,7 @@ describe("amico-run CLI", () => { // has no cloud config in scope (env pair cleared, cloud.json pointed at a // nonexistent file) — a local free solve must still complete, exit 0. const root = tmpRoot(); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const env = join(root, "env"); mkdirSync(env, { recursive: true }); writeFileSync(join(env, "Project.toml"), `[deps]\n`); @@ -287,7 +292,7 @@ describe("amico-run CLI", () => { it("SIGTERM to the CLI → abort lane, exit 130", async () => { const root = tmpRoot(); const julia = fakeJulia(root, "j", `console.log('READY'); setInterval(() => {}, 1000)`); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const code: number = await new Promise((resolveP) => { const child = execFile("node", [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia], { env: { ...process.env, ...hermeticOpsEnv() }, diff --git a/packages/amico-run/test/cloud_telemetry_preflight.test.ts b/packages/amico-run/test/cloud_telemetry_preflight.test.ts new file mode 100644 index 00000000..a00ebaa6 --- /dev/null +++ b/packages/amico-run/test/cloud_telemetry_preflight.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { execFile, execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, hermeticOpsEnv } from "./helpers.js"; +import { FakeCloud } from "./fake_cloud.js"; + +// A cloud script that never writes run.log cannot stream ANYTHING to the Run +// Inspector, and the user pays the full queue + instance-boot wait to discover it. +// +// Why run.log and not stdout: /solves/{id}/stats greps AMICODE_ITER out of the +// run.log the sidecar syncs from the working directory. The runner's stdout goes to +// the SSM command stream, which no API exposes — verified on task +// e4b25689 (HTTP 200, stats: []) and again on 975a7c07 and 64b8. +// +// Three cloud runs in two days hit this, every one a hand-authored script: +// x-gate-transmon-56 invented CubicSplinePulse kwargs, CallbackLogger, get_fidelity +// x-gate-transmon-hpc a Jul-28 script reused verbatim; ZeroOrderPulse into +// SplinePulseProblem, which Piccolo 1.19 rejects outright +// x-gate-transmon-hpc-3 CubicSplinePulseProblem (undefined) and +// AltissimoOptions(intermediate_callback=…) (no such field) +// None wrote run.log. LOCAL runs are unaffected: LocalExecutor pipes the child's +// stdout into run.log itself (local_executor.ts:293), so the script need not. + +const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); + +/** A hand-rolled script that prints telemetry but never writes the file. */ +const STDOUT_ONLY = `using Printf +@printf("AMICODE_ITER iter=1 f=1.0e-2 inf_pr=1e-8 inf_du=1e-6\\n") +println("DONE fidelity=0.99") +`; + +/** What the bundled template does: print AND append to run.log under TASK_ID. */ +const WRITES_RUN_LOG = `function emit(line) + println(line) + if haskey(ENV, "TASK_ID") + open("run.log", "a") do io + println(io, line) + end + end +end +emit("AMICODE_ITER iter=1 f=1.0e-2 inf_pr=1e-8 inf_du=1e-6") +emit("DONE fidelity=0.99") +`; + +function scriptWith(root: string, name: string, body: string): string { + const p = join(root, name); + writeFileSync(p, body); + return p; +} + +describe("cloud telemetry preflight", () => { + it("refuses a remote launch whose script never writes run.log", () => { + const root = tmpRoot(); + const script = scriptWith(root, "stdout_only.jl", STDOUT_ONLY); + let out = { code: 0, stderr: "" }; + try { + execFileSync("node", [BUNDLE, script, "--executor", "remote", "--runs-root", join(root, "runs")], { + encoding: "utf8", + // a cloud config must EXIST for the preflight to be reachable — with no + // connection at all the missing-connection error takes precedence, which is + // the more fundamental problem. Pointed at a dead port: the whole claim is + // that we refuse before ever contacting it (next test). + env: { + ...process.env, + ...hermeticOpsEnv(), + AMICO_CLOUD_URL: "http://127.0.0.1:1", + AMICO_CLOUD_TOKEN: "x", + }, + }); + } catch (e) { + const err = e as { status?: number; stderr?: string }; + out = { code: err.status ?? -1, stderr: err.stderr ?? "" }; + } + expect(out.code).toBe(64); + expect(out.stderr).toMatch(/cannot stream telemetry/); + // the message must be ACTIONABLE — the whole point is that a cloud failure + // names nothing, so this one has to name both the cause and the fix + expect(out.stderr).toMatch(/run\.log/); + expect(out.stderr).toMatch(/stdout does not reach it/); + expect(out.stderr).toMatch(/solve template/); + }); + + it("refuses BEFORE submitting — no cloud round trip, no run dir", () => { + const root = tmpRoot(); + const script = scriptWith(root, "stdout_only.jl", STDOUT_ONLY); + let stdout = ""; + try { + stdout = execFileSync("node", [BUNDLE, script, "--executor", "remote", "--runs-root", join(root, "runs")], { + encoding: "utf8", + // a cloud URL that would hang/refuse if we ever got as far as submitting + env: { ...process.env, ...hermeticOpsEnv(), AMICO_CLOUD_URL: "http://127.0.0.1:1", AMICO_CLOUD_TOKEN: "x" }, + }); + } catch (e) { + stdout = (e as { stdout?: string }).stdout ?? ""; + } + expect(stdout).not.toMatch(/AMICODE_FINISHED/); + }); + + it("passes a template-derived script and the run completes", async () => { + const fake = new FakeCloud(); + await fake.start(); + fake.state = { + task_status: "Running", + liveness: "alive", + iters: [], + runLog: "AMICODE_ITER iter=1 f=1.0e-2 inf_pr=1e-8 inf_du=1e-6", + finished: { status: "completed" }, + }; + try { + const root = tmpRoot(); + const script = scriptWith(root, "from_template.jl", WRITES_RUN_LOG); + const r = await new Promise<{ code: number; stdout: string; stderr: string }>((resolveP) => { + let stdout = ""; + let stderr = ""; + const child = execFile("node", [BUNDLE, script, "--executor", "remote", "--runs-root", join(root, "runs")], { + env: { ...process.env, ...hermeticOpsEnv(), AMICO_CLOUD_URL: fake.base, AMICO_CLOUD_TOKEN: fake.token }, + }); + child.stdout!.on("data", (d: string) => { + stdout += d; + }); + child.stderr!.on("data", (d: string) => { + stderr += d; + }); + child.on("exit", (c) => resolveP({ code: c ?? -1, stdout, stderr })); + }); + expect(r.stderr).not.toMatch(/cannot stream telemetry/); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0/); + } finally { + await fake.stop(); + } + }, 15000); + + it("leaves LOCAL runs alone — LocalExecutor writes run.log from stdout itself", () => { + // The same stdout-only script is perfectly fine locally, so the check must not + // fire there or it would break every ordinary free-tier solve. + const root = tmpRoot(); + const julia = fakeJulia( + root, + "j", + `console.log('AMICODE_ITER iter=1 f=1.0e-2 inf_pr=1e-8 inf_du=1e-6'); console.log('DONE f=0.99')`, + ); + const script = scriptWith(root, "stdout_only.jl", STDOUT_ONLY); + const stdout = execFileSync( + "node", + [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia], + { encoding: "utf8", env: { ...process.env, ...hermeticOpsEnv() } }, + ); + expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0/); + }); +}); diff --git a/packages/amico-run/test/fake_cloud.ts b/packages/amico-run/test/fake_cloud.ts index d8b88ba8..a86fbfca 100644 --- a/packages/amico-run/test/fake_cloud.ts +++ b/packages/amico-run/test/fake_cloud.ts @@ -19,11 +19,58 @@ export interface FakeState { finished?: { status: "completed" | "failed" | "aborted" }; liveness: "alive" | "gone"; iters: FakeIter[]; // Δ4 stats: full history each poll (client dedups on high-water) + /** A run.log body, served through the SAME transform the deployed /stats lambda + * applies (statsFromRunLog below). Prefer this to `iters` for anything that has + * to survive contact with the real service: `iters` lets a test hand the client + * pre-parsed records the live endpoint would never produce for our own + * telemetry lines, which is exactly how the drift keeps recurring. */ + runLog?: string; frame?: { iter: number; png_base64: string }; // Δ4 frames: newest only framesBroken?: boolean; // 500 the frames endpoint — resolution (a) lane pulse?: Array<{ raw: string }>; // Δ4 pulse: AMICODE_PULSE_META + AMICODE_PULSE lines, full history each poll } +/** Port of the deployed lambda's stats extraction, line for line + * (terraform/lambda_code/solves_poll/lambda_function.py:155-160): + * + * if "AMICODE_ITER" in line: + * payload = line.split("AMICODE_ITER", 1)[1].strip() + * stats.append(json.loads(payload) if payload.startswith("{") else {"raw": payload}) + * + * The consequence worth internalising: our telemetry lines are `iter=7 f=… ` — + * key=value, NOT JSON — so the live endpoint returns `{raw: "iter=7 f=…"}`, never + * `{iter: 7, f: …}`. A test that seeds the parsed form is testing a payload the + * server cannot send. That mistake shipped twice: it is why real cloud solves + * came back with zero iterations while every test passed. */ +export function statsFromRunLog(runLog: string): Array> { + const out: Array> = []; + for (const line of runLog.split("\n")) { + if (!line.includes("AMICODE_ITER")) continue; + const payload = line.split("AMICODE_ITER").slice(1).join("AMICODE_ITER").trim(); + if (payload.startsWith("{")) { + try { + out.push(JSON.parse(payload) as Record); + continue; + } catch { + out.push({ raw: line.trim() }); + continue; + } + } + out.push({ raw: payload }); + } + return out; +} + +/** The pulse half of the same grep. AMICODE_PULSE_META and AMICODE_PULSE lines are + * relayed VERBATIM as {raw} — they are never JSON — and the client dedups (meta + * once, pulse on the iteration high-water). */ +export function pulseFromRunLog(runLog: string): Array<{ raw: string }> { + return runLog + .split("\n") + .filter((l) => l.includes("AMICODE_PULSE")) + .map((l) => ({ raw: l.trim() })); +} + export class FakeCloud { readonly token = "test-token-abc"; readonly taskId = "task-0001"; @@ -96,13 +143,22 @@ export class FakeCloud { // the live payloads; a fake that agrees with the client instead of the server // proves nothing. if (url === `/solves/${this.taskId}/stats`) { - return send(200, { task_id: this.taskId, stats: this.state.iters, submitter: "test" }); + // runLog wins when set: it goes through the deployed lambda's own transform, + // so the test sees the records the live service would actually return. + const stats = this.state.runLog !== undefined ? statsFromRunLog(this.state.runLog) : this.state.iters; + return send(200, { task_id: this.taskId, stats, submitter: "test" }); } // pulse mirrors stats' shape: {task_id, pulse: [{raw}], submitter}, where the // cloud greps AMICODE_PULSE_META + AMICODE_PULSE lines out of the S3 run.log // (never JSON, so always {raw}). Full history each poll — the client dedups. if (url === `/solves/${this.taskId}/pulse`) { - return send(200, { task_id: this.taskId, pulse: this.state.pulse ?? [], submitter: "test" }); + // Same run.log, same reason as /stats: the deployed endpoint greps + // AMICODE_PULSE_META + AMICODE_PULSE out of it (never JSON, so always + // {raw}). Verified live — /pulse returns 200 {pulse: []} for a run whose + // run.log was never written, exactly like /stats. + const pulse = + this.state.runLog !== undefined ? pulseFromRunLog(this.state.runLog) : (this.state.pulse ?? []); + return send(200, { task_id: this.taskId, pulse, submitter: "test" }); } if (url === `/solves/${this.taskId}/frames`) { if (this.state.framesBroken) return send(500, { error: "frames unavailable" }); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index 498d68c7..d5a22772 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -14,8 +14,19 @@ export function tmpRoot(): string { * (found exactly that way, 2026-07-28). Points at an empty temp dir: absent * files are the fresh-install state every reader already fails safe to. * Spread it BEFORE per-test overrides so a test can still opt into an ops dir. */ -export function hermeticOpsEnv(): { AMICODE_OPS_DIR: string } { - return { AMICODE_OPS_DIR: mkdtempSync(join(tmpdir(), "amico-run-ops-")) }; +export function hermeticOpsEnv(): { AMICODE_OPS_DIR: string; AMICO_AUTHORING_FILE: string } { + const dir = mkdtempSync(join(tmpdir(), "amico-run-ops-")); + return { + AMICODE_OPS_DIR: dir, + // Pin authoring.json too, not just the ops dir. hpTierSelected() reads the + // entitlement-resolved allowlist as its SECOND signal, and a developer's real + // ~/.amico/authoring/authoring.json grants Piccolissimo — so without this a + // local-run test on this machine sees the cloud tier, promotes, and SUBMITS A + // REAL JOB to live staging. That has already happened once. The path + // deliberately does not exist: readAuthoring() then falls back to + // DEFAULT_ALLOWLIST, which carries no issimo package. + AMICO_AUTHORING_FILE: join(dir, "authoring-absent.json"), + }; } export function readToml(path: string): Record { diff --git a/packages/amico-run/test/setup.ts b/packages/amico-run/test/setup.ts index 4a2596a8..e7392399 100644 --- a/packages/amico-run/test/setup.ts +++ b/packages/amico-run/test/setup.ts @@ -21,3 +21,26 @@ process.env.AMICO_CRITIC_BIN = "/nonexistent/amico-test-guard/no-real-model-call if (!process.env.AMICO_LEDGER) { process.env.AMICO_LEDGER = "/nonexistent/amico-test-guard/ledger.jsonl"; } + +// Same reasoning again, for the cloud: NO TEST MAY EVER SUBMIT A REAL SOLVE. +// +// hpTierSelected() treats the entitlement-resolved allowlist in authoring.json as a signal that +// the Piccolissimo + Altissimo tier is active, and a developer's REAL +// ~/.amico/authoring/authoring.json grants Piccolissimo. So any test that launches without +// --executor promotes to remote, reads the developer's live cloud.json, and submits a billed +// staging job. That is not hypothetical: estimate.test.ts did exactly this and sat 13.5 minutes +// polling the real cloud before failing. +// +// Fails CLOSED the same way as the guards above — a path that cannot exist makes readAuthoring() +// fall back to DEFAULT_ALLOWLIST, which carries no issimo package, so the tier reads as the free +// local one. A test that WANTS the cloud tier writes its own authoring.json and points +// AMICO_AUTHORING_FILE at it (hermeticOpsEnv / authoringGrantingIssimo), which is explicit. +if (!process.env.AMICO_AUTHORING_FILE) { + process.env.AMICO_AUTHORING_FILE = "/nonexistent/amico-test-guard/authoring.json"; +} +// Belt to that brace: even with the tier misread, an absent cloud config makes the promotion +// refuse (exit 64) instead of reaching a real endpoint. Only set when the test has not chosen its +// own — FakeCloud-based tests pass AMICO_CLOUD_URL explicitly. +if (!process.env.AMICO_CLOUD_URL && !process.env.AMICO_CLOUD_TOKEN) { + process.env.AMICO_CLOUD_FILE = "/nonexistent/amico-test-guard/cloud.json"; +} diff --git a/packages/amico-run/test/solver_mode.test.ts b/packages/amico-run/test/solver_mode.test.ts index b8e3c11f..8cbe4606 100644 --- a/packages/amico-run/test/solver_mode.test.ts +++ b/packages/amico-run/test/solver_mode.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll } from "vitest"; import { execFile, execFileSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpRoot, fakeJulia } from "./helpers.js"; import { FakeCloud } from "./fake_cloud.js"; @@ -8,13 +8,37 @@ import { readSolverMode, solverModeFile } from "../src/solver_mode.js"; // Piccolissimo + Altissimo is a cloud-only tier. These tests pin the two halves // of that guarantee: the reader that decides which solver is selected, and the -// launch refusal that makes "cloud-only" true rather than merely advertised. +// launch behaviour that makes "cloud-only" true rather than merely advertised — +// a defaulted executor is PROMOTED to the cloud, an explicit --executor local is +// REFUSED. Both halves matter: promotion is what makes the tier automatic, and +// the refusal is what keeps it from being silently overridden. + +/** Stand-in for a template-derived cloud script: it appends telemetry to + * run.log, which is what the cloud actually greps and what the telemetry + * preflight requires. A bare stub would be refused, correctly. */ +const REMOTE_STUB = 'open("run.log", "a") do io\n println(io, "AMICODE_ITER iter=1 f=1e-2")\nend\n'; const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); }); +/** An authoring.json path that does NOT exist, so readAuthoring() falls back to + * DEFAULT_ALLOWLIST (no issimo package). Without this the suite reads the + * DEVELOPER's real allowlist, which grants Piccolissimo — every local-run case + * below would then promote and submit a real job to live staging. */ +function noAuthoring(root: string): string { + return join(root, "authoring-absent.json"); +} + +/** An authoring.json whose entitlement-resolved allowlist grants Piccolissimo — + * i.e. what session prep writes once the issimo entitlement is granted. */ +function authoringGrantingIssimo(root: string): string { + const p = join(root, "authoring.json"); + writeFileSync(p, JSON.stringify({ allowlist: ["Piccolo", "Piccolissimo"], support_set: [], verify_tolerance: 0.001 })); + return p; +} + function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { try { const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); @@ -63,32 +87,83 @@ describe("readSolverMode", () => { }); describe("Piccolissimo + Altissimo never solves locally", () => { - // The bug: selecting HP grants the `issimo` entitlement, so the import scan - // admits a local `using Piccolissimo` and the laptop precompiles the whole HP - // stack (IPOPT included) until amico-run's process-group timeout SIGTERMs it - // mid-precompile. Refusing the launch is the durable fix. - it("refuses a local launch and exits 64", () => { + // The bug this guards: selecting HP grants the `issimo` entitlement, so the + // import scan admits a local `using Piccolissimo` and the laptop precompiles the + // whole HP stack (IPOPT included) until amico-run's process-group timeout + // SIGTERMs it mid-precompile. + // + // A DEFAULTED executor is promoted to remote rather than refused. Refusing it + // (the original behaviour) meant every plain `amico-run script.jl` under the + // cloud tier exited 64 and depended on the agent reading the message and + // retrying — users saw that round-trip as a failed run. + it("promotes a defaulted launch to the cloud instead of refusing it", async () => { + const fake = new FakeCloud(); + await fake.start(); + fake.state = { + task_status: "Running", + liveness: "alive", + iters: [{ iter: 1, f: "1.0e-2", inf_pr: "1e-8", inf_du: "1e-6" }], // served as {stats} on the wire + finished: { status: "completed" }, + }; + try { + const root = tmpRoot(); + const ops = opsDirWith(root, JSON.stringify({ mode: "hp", status: "ready" })); + // a --julia that would SHOUT if it ever ran: promotion means it never does + const julia = fakeJulia(root, "j", `require('fs').writeFileSync(${JSON.stringify(join(root, "ran-locally"))}, 'x')`); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = await new Promise<{ code: number; stdout: string; stderr: string }>((resolveP) => { + let stdout = ""; + let stderr = ""; + // NO --executor flag: that is the whole point of this case + const child = execFile("node", [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia], { + env: { ...process.env, AMICODE_OPS_DIR: ops, AMICO_AUTHORING_FILE: noAuthoring(root), AMICO_CLOUD_URL: fake.base, AMICO_CLOUD_TOKEN: fake.token }, + }); + child.stdout!.on("data", (d: string) => { + stdout += d; + }); + child.stderr!.on("data", (d: string) => { + stderr += d; + }); + child.on("exit", (c) => resolveP({ code: c ?? -1, stdout, stderr })); + }); + expect(r.code).toBe(0); + expect(r.stderr).toMatch(/running in Harmoniqs Cloud/); + // it went to the cloud, not to the laptop + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0/); + expect(existsSync(join(root, "ran-locally"))).toBe(false); + } finally { + await fake.stop(); + } + }, 15000); + + // An EXPLICIT --executor local contradicts the selected tier. Silently inverting + // a flag the caller typed is worse than an error, so this one still exits 64. + it("refuses an explicit --executor local and exits 64", () => { const root = tmpRoot(); const ops = opsDirWith(root, JSON.stringify({ mode: "hp", status: "ready" })); const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); - const script = fakeJulia(root, "s.jl", ""); - const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia], { AMICODE_OPS_DIR: ops }); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = run([script, "--executor", "local", "--runs-root", join(root, "runs"), "--julia", julia], { + AMICODE_OPS_DIR: ops, + }); expect(r.code).toBe(64); expect(r.stderr).toContain("Harmoniqs Cloud"); expect(r.stderr).toMatch(/never solves locally/); // it must name the way out, in both directions - expect(r.stderr).toMatch(/--executor remote/); + expect(r.stderr).toMatch(/cloud is automatic for this tier/); expect(r.stderr).toMatch(/switch the solver to Piccolo/); }); - // The refusal has to cover a bare `amico-run script.jl` too: runGate only sees - // --spec runs, so a gate-only check would leave the commonest path open. - it("refuses even with no --spec (the gate never runs on that path)", () => { + // Both behaviours have to cover a bare `amico-run script.jl` too: runGate only + // sees --spec runs, so a gate-only check would leave the commonest path open. + it("covers the no --spec path (the gate never runs there)", () => { const root = tmpRoot(); const ops = opsDirWith(root, JSON.stringify({ mode: "hp", status: "ready" })); const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); - const script = fakeJulia(root, "s.jl", ""); - const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia], { AMICODE_OPS_DIR: ops }); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = run([script, "--executor", "local", "--runs-root", join(root, "runs"), "--julia", julia], { + AMICODE_OPS_DIR: ops, + }); expect(r.code).toBe(64); // and it dies BEFORE any run dir exists — no half-run to clean up expect(r.stdout).not.toContain("AMICODE_FINISHED"); @@ -112,12 +187,12 @@ describe("Piccolissimo + Altissimo never solves locally", () => { try { const root = tmpRoot(); const ops = opsDirWith(root, JSON.stringify({ mode: "hp", status: "ready" })); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const r = await new Promise<{ code: number; stdout: string; stderr: string }>((resolveP) => { let stdout = ""; let stderr = ""; const child = execFile("node", [BUNDLE, script, "--executor", "remote", "--runs-root", join(root, "runs")], { - env: { ...process.env, AMICODE_OPS_DIR: ops, AMICO_CLOUD_URL: fake.base, AMICO_CLOUD_TOKEN: fake.token }, + env: { ...process.env, AMICODE_OPS_DIR: ops, AMICO_AUTHORING_FILE: noAuthoring(root), AMICO_CLOUD_URL: fake.base, AMICO_CLOUD_TOKEN: fake.token }, }); child.stdout!.on("data", (d: string) => { stdout += d; @@ -139,8 +214,8 @@ describe("Piccolissimo + Altissimo never solves locally", () => { const root = tmpRoot(); const ops = opsDirWith(root, JSON.stringify({ mode: "piccolo", status: "ready" })); const julia = fakeJulia(root, "j", `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`); - const script = fakeJulia(root, "s.jl", ""); - const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia], { AMICODE_OPS_DIR: ops }); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia], { AMICODE_OPS_DIR: ops, AMICO_AUTHORING_FILE: noAuthoring(root) }); expect(r.code).toBe(0); expect(r.stderr).not.toMatch(/never solves locally/); }); @@ -148,10 +223,80 @@ describe("Piccolissimo + Altissimo never solves locally", () => { it("no solver-mode.json at all → local runs work (fresh install)", () => { const root = tmpRoot(); const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); - const script = fakeJulia(root, "s.jl", ""); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia], { AMICODE_OPS_DIR: join(root, "absent-ops"), + AMICO_AUTHORING_FILE: noAuthoring(root), + }); + expect(r.code).toBe(0); + }); +}); + +// The 2026-08-05 field report: "it ran a solve but it used a vetted template so it +// ran locally", with Piccolissimo + Altissimo selected. Reproduced from the real +// machine state — solver-mode.json read `piccolo` and was dated a week earlier, +// while the issimo entitlement WAS granted, cloud.json was live, and Harmoniqs +// Cloud showed connected. Routing keyed off that one stale file, so the paid tier +// silently reverted to a local IPOPT solve and no surface said so. +describe("a stale solver-mode.json cannot silently downgrade the paid tier", () => { + it("promotes to the cloud on the ENTITLEMENT alone when the mode file is stale", async () => { + const fake = new FakeCloud(); + await fake.start(); + fake.state = { task_status: "Running", liveness: "alive", iters: [], finished: { status: "completed" } }; + try { + const root = tmpRoot(); + // exactly the observed state: the file says piccolo, the entitlement says HP + const ops = opsDirWith(root, JSON.stringify({ mode: "piccolo", status: "ready", switched_at: "2026-07-28T22:15:00.000Z" })); + const julia = fakeJulia(root, "j", `require('fs').writeFileSync(${JSON.stringify(join(tmpRoot(), "should-not-exist"))}, 'x')`); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = await new Promise<{ code: number; stderr: string; stdout: string }>((resolveP) => { + let stdout = ""; + let stderr = ""; + const child = execFile("node", [BUNDLE, script, "--runs-root", join(root, "runs"), "--julia", julia], { + env: { + ...process.env, + AMICODE_OPS_DIR: ops, + AMICO_AUTHORING_FILE: authoringGrantingIssimo(root), // the second signal + AMICO_CLOUD_URL: fake.base, + AMICO_CLOUD_TOKEN: fake.token, + }, + }); + child.stdout!.on("data", (d: string) => { stdout += d; }); + child.stderr!.on("data", (d: string) => { stderr += d; }); + child.on("exit", (c) => resolveP({ code: c ?? -1, stdout, stderr })); + }); + expect(r.code).toBe(0); + expect(r.stderr).toMatch(/running in Harmoniqs Cloud/); + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0/); + } finally { + await fake.stop(); + } + }, 15000); + + it("an explicit --executor local is refused on the entitlement alone too", () => { + const root = tmpRoot(); + const ops = opsDirWith(root, JSON.stringify({ mode: "piccolo", status: "ready" })); + const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = run([script, "--executor", "local", "--runs-root", join(root, "runs"), "--julia", julia], { + AMICODE_OPS_DIR: ops, + AMICO_AUTHORING_FILE: authoringGrantingIssimo(root), + }); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/never solves locally/); + }); + + it("NEITHER signal → local, so the free tier is untouched", () => { + // The fail-safe direction. This is the common case and must not regress. + const root = tmpRoot(); + const ops = opsDirWith(root, JSON.stringify({ mode: "piccolo", status: "ready" })); + const julia = fakeJulia(root, "j", `console.log('DONE f=0.99')`); + const script = fakeJulia(root, "s.jl", REMOTE_STUB); + const r = run([script, "--runs-root", join(root, "runs"), "--julia", julia], { + AMICODE_OPS_DIR: ops, + AMICO_AUTHORING_FILE: noAuthoring(root), }); expect(r.code).toBe(0); + expect(r.stderr).not.toMatch(/Harmoniqs Cloud/); }); }); diff --git a/packages/amico-run/test/subcommands.test.ts b/packages/amico-run/test/subcommands.test.ts index 6f374fae..2fb88dc2 100644 --- a/packages/amico-run/test/subcommands.test.ts +++ b/packages/amico-run/test/subcommands.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { readToml } from "./helpers.js"; +import { readToml, hermeticOpsEnv } from "./helpers.js"; const BUNDLE = join(__dirname, "..", "dist", "amico-run.js"); beforeAll(() => { @@ -160,3 +160,62 @@ describe("sandbox — bundled-asset production path", () => { rmSync(target, { recursive: true, force: true }); }); }); + +// "if I say run a piccolissimo altissimo solve it might use piccolo because the +// vetted template uses piccolo" (2026-08-06). AGENTS.md documents the vetted +// workflow as "copy template_path", and template_path was always the BUNDLED +// registry file — whose {{SOLVER}} placeholder is unsubstituted and degrades to +// ipopt. So an agent following the workflow got Piccolo/IPOPT no matter what the +// solver toggle said, while the AGENTS.md preamble pointed at a different file +// (the staged copy). Two instructions, two files, one of them wrong. +describe("resolve returns the session's STAGED template, not the bundled one", () => { + function authoringWith(extra: Record): string { + const dir = mkdtempSync(join(tmpdir(), "auth-")); + // REGISTRY is the registry.toml BODY; materialize it so `resolve` can read it + // and so template_path resolves relative to a real directory. + const registry = join(dir, "registry.toml"); + writeFileSync(registry, REGISTRY); + const p = join(dir, "authoring.json"); + writeFileSync( + p, + JSON.stringify({ + allowlist: ["Piccolo", "Piccolissimo"], + support_set: ["JLD2", "TOML", "Printf", "CairoMakie"], + registry, + verify_tolerance: 0.001, + ...extra, + }), + ); + return p; + } + + function resolveWith(authoring: string): Record { + const out = execFileSync( + "node", + [BUNDLE, "resolve", "--platform", "transmon", "--kind", "gate_synthesis", "--size", "1"], + { encoding: "utf8", env: { ...process.env, ...hermeticOpsEnv(), AMICO_AUTHORING_FILE: authoring } }, + ); + return JSON.parse(out) as Record; + } + + it("prefers staged_template when the session staged one", () => { + const staged = join(mkdtempSync(join(tmpdir(), "proj-")), "solve.jl"); + writeFileSync(staged, 'SOLVER = let s = "altissimo"\n Symbol(s)\nend\n'); + const out = resolveWith(authoringWith({ staged_template: staged })); + expect(out.tier).toBe("vetted"); + expect(out.template_path).toBe(staged); + }); + + it("falls back to the registry path when nothing was staged (bare dev invocation)", () => { + const out = resolveWith(authoringWith({})); + expect(out.tier).toBe("vetted"); + expect(String(out.template_path)).toMatch(/solve_template\.jl$/); + }); + + it("falls back when staged_template points at a file that is gone", () => { + // A stale authoring.json from a previous session must not send the agent to a + // path that no longer exists — that would be a worse failure than the old one. + const out = resolveWith(authoringWith({ staged_template: "/nonexistent/staged/solve.jl" })); + expect(String(out.template_path)).toMatch(/solve_template\.jl$/); + }); +}); diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts index d76c6066..742b3b90 100644 --- a/packages/extension/media/ui/atoms/pill.ts +++ b/packages/extension/media/ui/atoms/pill.ts @@ -24,11 +24,26 @@ defineStyle( } .pill.done { color: var(--color-ok); } .pill.failed { color: var(--color-fail); } + /* cloud — WHERE the run executes, not how it is going. The one badge here that + * is a filled brand swatch: "this is the paid tier, running in Harmoniqs + * Cloud" is the strongest claim the topbar makes, and the lemon fill is how + * this product says "special". Obeys the brand rule in brand.css — yellow is a + * FILL, never an ink — so the label is --color-on-accent (black, 18.7:1) and + * the edge is the theme-solved hairline rather than the lemon trying to bound + * itself against a light background. */ + .pill.cloud { color: var(--color-on-accent); background: var(--color-accent-fill); + border-color: var(--color-accent-edge); } + /* The status dot becomes a cloud glyph. Declared AFTER .no-dot so a caller + * passing dot:false cannot silently erase it (equal specificity, later wins). */ + .pill.cloud::before { content: "☁"; width: auto; height: auto; background: none; + border-radius: 0; font-size: 1.15em; line-height: 1; } @keyframes pill-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } } `, ); -export type PillState = "idle" | "running" | "done" | "failed"; +/** Process states, plus `cloud` — a BADGE state describing a property of the run + * (where it executes) rather than how it is progressing. */ +export type PillState = "idle" | "running" | "done" | "failed" | "cloud"; export interface PillOptions { /** Status dot before the label (default true). Badges pass false. */ diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index 8f831a1f..0e86ad94 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -105,10 +105,18 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { brand.className = "row gap-sm brand"; brand.append(logo({ variant: "reduced" }), text("", "Run Inspector").el); + // WHERE this run executes. Hidden by default and revealed only for a cloud + // run, so the badge carries information when it appears — a local run gets no + // "LOCAL" chrome for users to learn to ignore. It sits beside the run id (the + // answer to "what am I looking at") rather than out by the status pill, which + // is about how the solve is going. + const location = pill("cloud", "Harmoniqs Cloud"); + location.el.hidden = true; + const topbar = document.createElement("div"); topbar.className = "row wrap"; status.el.classList.add("push-end"); - topbar.append(brand, runLabel.el, status.el); + topbar.append(brand, runLabel.el, location.el, status.el); const grid = document.createElement("div"); grid.className = "insp-metrics"; @@ -196,6 +204,10 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { case "runlabel": runLabel.set(String(msg.text ?? "")); break; + case "location": + // Cloud runs announce themselves; anything else leaves the topbar bare. + location.el.hidden = msg.cloud !== true; + break; case "timing": { if (msg.terminal) { clearTick(); diff --git a/packages/extension/scores/pulse-designer/templates/solve.jl b/packages/extension/scores/pulse-designer/templates/solve.jl index f0dee59a..bff3c1de 100644 --- a/packages/extension/scores/pulse-designer/templates/solve.jl +++ b/packages/extension/scores/pulse-designer/templates/solve.jl @@ -18,12 +18,59 @@ T = 10.0 # gate time (ns) N = 50 # timesteps drive_max = 0.2 # per-quadrature drive bound (GHz) max_iter = 60 -SOLVER = :ipopt # :ipopt (default) or :altissimo (High-Performance + Cloud) # ───────────────────────────────────────────────────────────────────────── +# The solver follows the SELECTED SOLVER MODE, substituted at session prep — it is +# NOT an authoring decision. Piccolo mode → :ipopt; Piccolissimo + Altissimo (the +# paid cloud tier) → :altissimo, every run, automatically. Do not hand-edit this: +# the two backends need different callback wiring (below), and a mismatch between +# the selected tier and the solver is how a "High-Performance" run quietly ends up +# on IPOPT. +# +# Written as a substituted STRING rather than a bare `{{SOLVER}}` symbol so that an +# unsubstituted template is still valid Julia: if session prep could not stage a +# copy, this reads as :ipopt (a working local solve) instead of raising a syntax +# error on the placeholder itself. +SOLVER = let s = "{{SOLVER}}" + Symbol(startswith(s, "{{") ? "ipopt" : s) +end + SOLVER in (:ipopt, :altissimo) || error("SOLVER must be :ipopt or :altissimo, got $SOLVER") if SOLVER === :altissimo @eval using Piccolissimo # AltissimoOptions lives here, not in Piccolo + @eval using DirectTrajOpt + + # ── dispatch bridge: DirectTrajOpt 0.9.7 moved the backend extension point ── + # DTO 0.9.7 renamed it from `Solvers.solve!` to `_solve`, and its fallback only + # @error-LOGS and returns nothing. Piccolissimo (through 0.2.0) still defines + # the OLD name, so under 0.9.7 nothing matches and every Altissimo solve is a + # silent NO-OP that still reports success: `iterations = 0`, fidelity left at + # the random initial guess, FINISHED completed/exit 0. Verified on this machine + # (run r20260729-103718Z-12e3: 0 iters, fidelity 0.048, reported "converged"). + # + # Altissimo is not the bug — its host interface moved out from under it. Until + # Piccolissimo migrates upstream, bridge it here: the solve script ships per + # submission, so this reaches the cloud runner with no image rebake. + # + # Guard on WHICH method would be called, not on `methods(...)` being empty: + # DTO has two fallbacks (one typed `Any`, one `AbstractSolverOptions`) and + # AltissimoOptions matches both, so an emptiness test never installs the bridge. + _alt_dispatch = try + m = which(DirectTrajOpt._solve, (DirectTrajOpt.DirectTrajOptProblem, Piccolissimo.AltissimoOptions)) + string(m.sig.parameters[3]) + catch + "none" + end + if _alt_dispatch in ("Any", "AbstractSolverOptions", "DirectTrajOpt.AbstractSolverOptions", "none") + @eval DirectTrajOpt._solve( + prob::DirectTrajOpt.DirectTrajOptProblem, + options::Piccolissimo.AltissimoOptions; + kwargs..., + ) = DirectTrajOpt.Solvers.solve!(prob, options; kwargs...) + println("AMICODE_NOTE bridged AltissimoOptions onto DirectTrajOpt._solve " * + "(DTO $(pkgversion(DirectTrajOpt)) moved the extension point; was resolving to $_alt_dispatch)") + flush(stdout) + end end # ── telemetry sink ─────────────────────────────────────────────────────────── @@ -158,6 +205,7 @@ end # derive from those rather than emitting NaN — a real number the client can plot # beats a placeholder it has to special-case. function alt_cb(x, info) + alt_cb_fired[] = true # tells the stdout bridge below to stand down (one numbering scheme per run) k = Int(info.outer_iter); iters[] = k ok = pulse_emit(x, k) # frames + AMICODE_PULSE + cooperative STOP inf_pr = haskey(info, :inf_pr) ? info.inf_pr : max(info.eq_viol, info.ineq_viol) @@ -166,18 +214,188 @@ function alt_cb(x, info) return ok end +# Altissimo telemetry needs TWO independent sources, because neither one is +# reliable across the Piccolissimo versions in the wild: +# 1. alt_cb above — the good path: it carries frames as well as numbers. But it +# only fires where Piccolissimo forwards a caller `callback` into +# Altissimo.optimize!. Piccolissimo 0.2.0 declares +# `solve!(prob, ::AltissimoOptions; kwargs...)` and forwards a HARDCODED +# whitelist (tol, polish*, …) — `callback` is not on it, so on 0.2.0 and the +# current cloud image alt_cb never runs at all. +# 2. the stdout bridge below — the floor: Altissimo's own iteration table always +# prints under `verbose`, on old builds too, so translating those rows into +# AMICODE_ITER gives the Run Inspector a live curve no matter what the +# installed Piccolissimo forwards. +# Belt and braces on purpose. With only (1), an Altissimo run on the shipped image +# reports `iterations = 0` and the Inspector stays dark — that is exactly the +# 2026-07-29 failure (fidelity 0.048 reported as a converged result). +# +# Row shapes, both from Altissimo/src/Optimizer.jl: +# inner step " %5s %13.6e %10.3e %10.3e …" iter column is "·" +# final outer " %5d %13.6e %10.3e %10.3e …" iter column is the outer index +# Columns 2-4 are objective, inf_pr, and the dual measure (‖∇L‖ inner / +# stationarity outer) — the same three the IPOPT path plots. +# +# Numbering is SEQUENTIAL over rows, not read out of the iter column: that column +# is "·" for every inner step and only becomes an integer on the last outer +# iteration, so trusting it yields a single point at the end (verified: a 5-outer +# run printed 1 integer row and ~200 "·" rows). Each inner row is one optimizer +# step, so counting rows gives the dense curve IPOPT streams locally. +const ALT_ROW = r"^\s*(?:·|\d+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s" +alt_cb_fired = Ref(false) + +# WHICH channel carries this run, decided BEFORE the solve starts. +# +# Deciding reactively — stand the bridge down once alt_cb first fires — is a race, +# and it loses. alt_cb fires at the END of an outer iteration, so by then the +# bridge has already translated every inner row of outer #1 under its own +# numbering. Observed on Piccolissimo 0.3.1: the curve climbed to 51 (bridge, +# counting inner rows) then reset to 1 (callback, counting outer iterations) — +# 51 → 1 → 1 → 2 → 2 …, which the Inspector plots as a sawtooth and which reads +# as a diverging solve. +# +# The two scales are irreconcilable (dense inner steps vs sparse outer +# iterations), so exactly ONE channel may emit. Prefer the callback wherever it is +# forwarded: it carries pulse frames as well as numbers, which the table cannot. +# +# A version gate, because forwarding is not introspectable: Piccolissimo 0.3.x +# passes `callback` into Altissimo.optimize!; 0.2.x drops it via a hardcoded kwarg +# whitelist. alt_cb_fired stays as a belt — if a backport forwards it on an older +# version, the bridge still stands down once the callback proves itself. +const CB_FORWARDED = try + pkgversion(Piccolissimo) >= v"0.3.0" +catch + false +end + +"""Run the Altissimo solve, mirroring its verbose table into AMICODE_ITER lines. + +Writes through to the ORIGINAL stdout and appends run.log directly instead of +calling `emit()`: stdout is redirected for the duration of the solve, so emit() +would feed the very pipe this reader is draining.""" +function solve_altissimo_streaming(qcp, opts, cb) + real_out = stdout + pipe = Pipe() + Base.link_pipe!(pipe; reader_supports_async = true, writer_supports_async = true) + seq = Ref(0) + reader = @async begin + for line in eachline(pipe) + println(real_out, line) # the raw table still reaches the log + flush(real_out) + # Where the callback IS forwarded it supersedes this bridge: it numbers + # by outer iteration and carries frames, and two numbering schemes + # interleaved in one run.log would plot as a sawtooth. + (CB_FORWARDED || alt_cb_fired[]) && continue + m = match(ALT_ROW, line) + m === nothing && continue + # The captures must be REAL numbers. `[-+0-9.eE]+` also matches the + # bare "-" Altissimo prints as a placeholder in columns that do not + # apply to a row, which shipped as `AMICODE_ITER iter=1 f=-` on a live + # cloud run (task c584) — the Inspector then plots a non-numeric + # objective for the first points of the curve. + vals = map(c -> tryparse(Float64, c), (m.captures[1], m.captures[2], m.captures[3])) + any(isnothing, vals) && continue + seq[] += 1 + iters[] = seq[] + out = @sprintf("AMICODE_ITER iter=%d f=%s inf_pr=%s inf_du=%s", + seq[], m.captures[1], m.captures[2], m.captures[3]) + println(real_out, out) + flush(real_out) + if CLOUD_RUN + try + open("run.log", "a") do io + println(io, out) + end + catch # telemetry must never take down a solve + end + end + end + end + try + redirect_stdout(pipe) do + solve!(qcp; options = opts, callback = cb) + end + finally + close(pipe.in) + try + wait(reader) + catch + end + close(pipe) + end + # Say which channel carried the run — the two produce different iteration + # SCALES (outer iterations vs inner steps), so a reader comparing two runs + # needs to know which they are looking at. + if alt_cb_fired[] + emit("AMICODE_NOTE Altissimo telemetry came from the solver callback " * + "(Piccolissimo $(pkgversion(Piccolissimo)) forwards it): iterations count OUTER " * + "iterations, and per-iteration pulse frames are available") + else + emit("AMICODE_NOTE Piccolissimo $(pkgversion(Piccolissimo)) does not forward `callback` to " * + "Altissimo, so iterations were read from the solver's own table ($(seq[]) rows, one per " * + "INNER step) and per-iteration pulse frames are unavailable; the final pulse is still written") + end +end + +# Fingerprint the stack this run actually used. A cloud run executes against the +# runner's BAKED bundle, not the caller's environment, so "which Piccolissimo was +# that?" is otherwise unanswerable after the fact — and it is the first question +# every cloud failure raises. Emitted before the solve so it survives a solve that +# dies. Also records whether the callback channel is live, since the two channels +# count different things (outer iterations vs inner steps). +emit("AMICODE_ENV piccolo=$(pkgversion(Piccolo)) " * + (SOLVER === :altissimo ? + "piccolissimo=$(pkgversion(Piccolissimo)) dto=$(pkgversion(DirectTrajOpt)) " * + "callback_forwarded=$(CB_FORWARDED) " : "") * + "julia=$(VERSION) solver=$(SOLVER)") + +# Any exception from here on is reported INTO run.log before it propagates. +# +# On a cloud run the exception is otherwise invisible: julia's stderr goes to the +# SSM command stream, which no API exposes, so the user gets `failed, exit 1` and +# nothing else — that is exactly what happened to task 582a, which reached +# AMICODE_PULSE_META and then died with no recoverable reason. emit() writes +# run.log, which the sidecar syncs and the poller greps, so this makes the next +# failure self-diagnosing without waiting on an infra change. +# +# Rethrown, not swallowed: the run must still FAIL. A solve that reports success +# after an exception is the silent-no-op class of bug this template already guards +# against elsewhere. +function report_and_rethrow(e, bt) + try + emit("AMICODE_ERROR $(sprint(showerror, e))") + for frame in first(stacktrace(bt), 12) + emit("AMICODE_ERROR at $(frame)") + end + catch + # never let the reporter mask the original failure + end + rethrow(e) +end + t0 = time() -if SOLVER === :altissimo +try + if SOLVER === :altissimo # The budget goes on the OPTIONS, not as a solve! kwarg. solve!(::AltissimoOptions) # forwards a hardcoded list to Altissimo.optimize! and swallows the rest, so a # `max_iter =` here is silently dropped and the solve quietly runs Altissimo's # default 20 outer iterations instead of the FILL-IN value. - solve!(qcp; options = Piccolissimo.AltissimoOptions(max_outer_iter = max_iter), callback = alt_cb) + # + # verbose = true is load-bearing, not chatter: it is what makes the iteration + # table — and therefore the stdout telemetry bridge above — exist at all. + solve_altissimo_streaming( + qcp, + Piccolissimo.AltissimoOptions(max_outer_iter = max_iter, verbose = true), + alt_cb, + ) else solve!(qcp; max_iter = max_iter, print_level = 1, options = IpoptOptions(intermediate_callback = pulse_emit), callback = CB.callback_factory(cb_log)) end +catch e + report_and_rethrow(e, catch_backtrace()) +end wall = time() - t0 # Fidelity over the COMPUTATIONAL subspace, from a fresh high-tolerance rollout. @@ -215,4 +433,16 @@ open("result.toml.tmp", "w") do io )) end mv("result.toml.tmp", "result.toml"; force = true) + +# A solve that recorded ZERO iterations did not optimize anything — the fidelity +# above is the random initial guess. Say so instead of letting FINISHED's +# completed/exit-0 read as a converged result downstream. This is the failure that +# hid a silently no-op'd Altissimo backend behind a "converged" badge +# (iterations = 0, fidelity = 0.048, 2026-07-29): the run LOOKED successful, so +# nobody checked. Partial artifacts are still written — the run is recorded, it +# just stops claiming a result it does not have. +if iters[] == 0 + emit("AMICODE_WARN no iterations were recorded — the optimizer never reported progress, so " * + "fidelity=$(fid) is the INITIAL guess, NOT a converged result (solver=$(SOLVER))") +end emit("DONE fidelity=$(fid)") diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 1af702ad..6fd9082a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -37,7 +37,7 @@ import { OpencodeEventClient } from "./sse_client"; import { RunsManager } from "./runs_manager"; import { stageDemoRun } from "./demo_replay"; import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runLogMtime } from "./run_controls"; -import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode"; +import { watchSolverMode, applyEntitlementForMode, reconcileSolverMode } from "./solver_mode"; import { runSetCloudKeyCommand } from "./cloud_key"; import { amicodeOpsDir } from "./substrate/vault_store"; import { stagePasqalConnector } from "./pasqal_assets"; @@ -430,11 +430,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // MODE-SELECTED vetted template: HP sessions get the Piccolissimo variant // (same run-dir contract, spline solver layer). An AGENTS.md instruction // can't beat the procedural template path — the file itself must swap. - templateSrc: path.resolve( - ctx.extensionPath, - "templates", - readSolverModeState().mode === "hp" ? "solve_template_hp.jl" : "solve_template.jl", - ), + templateSrc: path.resolve(ctx.extensionPath, "scores", "pulse-designer", "templates", "solve.jl"), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), skillLibraryRoots: cfgLibraryRoots(), @@ -565,6 +561,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); + // Before watching for NEW switches, repair a half-landed old one. The mode + // file only changes on a `status:"switching"` request, so a dropped write + // leaves it stale while the issimo entitlement says otherwise — and every + // cloud decision (routing, template SOLVER, the app's own toggle) reads the + // file. Observed 2026-08-05: file said piccolo (Jul 28) with issimo granted + // and Harmoniqs Cloud connected, so a paid-tier solve ran locally on IPOPT + // and nothing anywhere said so. + { + const { healed, mode } = reconcileSolverMode(); + if (healed) + opencodeChannel.appendLine( + `[solver] solver-mode.json disagreed with the issimo entitlement — healed to ${mode}. ` + + `A switch request lost its write; the tier is ${mode}.`, + ); + } // Solver-mode switcher (rchari/solver-wire): the app's toggle POSTs // {status:"switching"}; we do the REAL switch — grant/revoke the issimo // entitlement, re-prep the session project (skills/scores/allowlist follow @@ -578,11 +589,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), // Same mode-selection as boot; `mode` is the requested target of THIS // switch (the state file still reads status:"switching" here). - templateSrc: path.resolve( - ctx.extensionPath, - "templates", - mode === "hp" ? "solve_template_hp.jl" : "solve_template.jl", - ), + templateSrc: path.resolve(ctx.extensionPath, "scores", "pulse-designer", "templates", "solve.jl"), juliaProject: resolveJuliaProject( vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), ), @@ -710,11 +717,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const port = vscode.workspace.getConfiguration("amicode").get("opencodePort", 0); const project2 = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), - templateSrc: path.resolve( - ctx.extensionPath, - "templates", - readSolverModeState().mode === "hp" ? "solve_template_hp.jl" : "solve_template.jl", - ), + templateSrc: path.resolve(ctx.extensionPath, "scores", "pulse-designer", "templates", "solve.jl"), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), skillLibraryRoots: cfgLibraryRoots(), diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 5eace34c..4ccdacc8 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -15,7 +15,7 @@ import { type LibraryRoot, type LibraryRootSpec, } from "./scores/package_skills"; -import { readSolverModeState } from "./solver_mode"; +import { effectiveSolverMode } from "./solver_mode"; import { buildRoutingSection, readRoutingContext } from "./routing"; import { readProfileMd, @@ -232,6 +232,13 @@ export function writeAuthoringConfig( entitlementsDir: string, scoresRoot: string = DEFAULT_SCORES_ROOT, skills: SkillIndexEntry[] = [], + /** The session's STAGED solve template — the copy with SOLVER already + * substituted from the selected tier. amico-run's `resolve` returns this as + * template_path so the documented vetted workflow ("copy template_path") lands + * on the same file AGENTS.md points at. Without it, resolve hands back the + * BUNDLED registry path, whose {{SOLVER}} is unsubstituted and degrades to + * ipopt — so an HP user following the workflow silently got IPOPT. */ + stagedTemplate?: string, ): void { try { const ents = readLocalEntitlements(entitlementsDir); @@ -272,6 +279,7 @@ export function writeAuthoringConfig( exemplars: AUTHORING_ASSETS.exemplars, verify_harness: AUTHORING_ASSETS.verifyHarness, verify_tolerance: tolerance, + ...(stagedTemplate ? { staged_template: stagedTemplate } : {}), // Additive session record (spec §3): the dual-source skill index the // agent was given. amico-run ignores unknown fields; schema_version stays 1. skills, @@ -294,7 +302,7 @@ export function writeAuthoringConfig( * connection, else exit 64); this section makes the agent do the right thing * and, crucially, BLOCK-WITH-PROMPT when no cloud key is connected. */ export function solverModeSection(): string { - if (readSolverModeState().mode !== "hp") return ""; + if (effectiveSolverMode() !== "hp") return ""; const cloudConnected = !!process.env.AMICO_CLOUD_URL || fs.existsSync(path.join(os.homedir(), ".amico", "cloud.json")); const routing = cloudConnected @@ -302,15 +310,18 @@ export function solverModeSection(): string { "mode, so never ask the user where a solve should run. Author it as: " + '`tier="hpc"`, `executor="remote"`, `env.kind="provisioned"` (via `amico-run --spec ' + " --executor remote`). The runner image has Piccolissimo/Altissimo pre-baked, so there " + - "is NO local precompile and NO sandbox — never author a sandbox env for HP. A local launch is " + - "REFUSED by amico-run while this solver is selected (exit 64), so attempting one only wastes a turn. " + - "Live iteration frames stream to the Inspector; note that per-iteration AMICODE_ITER stats + the " + - "cooperative Stop are not yet available on the cloud bundle, and re-rollout verification is skipped " + - "for cloud runs (say so). Only claim cloud execution when the launch actually used `--executor remote`." + "is NO local precompile and NO sandbox — never author a sandbox env for HP. amico-run routes this " + + "tier to the cloud on its own (a launch with no `--executor` is promoted to remote) and REFUSES an " + + "explicit `--executor local` (exit 64), so attempting a local run only wastes a turn. " + + "Per-iteration `AMICODE_ITER` stats and live frames both stream to the Inspector. Two real limits to " + + "state plainly if they come up: the cooperative Stop needs the solver callback, which the cloud " + + "bundle's Piccolissimo does not forward to Altissimo (so Stop may not interrupt a cloud solve), and " + + "re-rollout verification is skipped for cloud runs. " + + "Only claim cloud execution when the launch actually ran remotely." : "Harmoniqs Cloud is NOT connected (no API key). Piccolissimo + Altissimo is a PAID cloud tier and " + "CANNOT run locally — do NOT attempt a local Piccolissimo solve (it will fail three ways: amico-run " + - "refuses a local launch in this mode, the private package can't be instantiated in a sandbox, and " + - "the gate rejects a local hpc run). Instead, STOP and tell the user: " + + "refuses the launch in this mode and tells the user to connect, the private package can't be " + + "instantiated in a sandbox, and the gate rejects a local hpc run). Instead, STOP and tell the user: " + '"Piccolissimo + Altissimo needs a Harmoniqs Cloud connection — click **Piccolissimo + Altissimo** ' + "in the model · solver control on the dashboard and connect your API key there (or run **Amico: " + 'Connect Cloud**, which opens the same flow)." Offer to switch back to the free local Piccolo solver ' + @@ -326,23 +337,30 @@ export function solverModeSection(): string { "`EmbeddedOperator`, `UnitaryTrajectory` — every problem-setup name comes from Piccolo. The failure is " + "an UndefVarError at load time, before any solve starts, and on a cloud run you pay the full queue and " + "instance-boot wait before seeing it. " + - "**Solver backend:** the default remains IPOPT (`IpoptOptions`), which is what streams per-iteration " + - "telemetry — its `intermediate_callback` produces the Inspector's frames and the `AMICODE_ITER` lines. " + - "If the researcher asks for the **Altissimo** backend (the augmented-Lagrangian GPU solver, " + - "`AltissimoOptions`), switch it by setting **`SOLVER = :altissimo`** in the template's FILL-IN block — that " + - "one line is the whole change. Do NOT hand-roll the solve call: the template already re-hangs BOTH telemetry " + - "channels onto Altissimo's `(x, info)` hook (the frames come off `IpoptOptions.intermediate_callback`, which " + - "`AltissimoOptions` does not have, so a hand-written call loses the Inspector's frames as well as its " + - "numbers), passes the budget as `AltissimoOptions(max_outer_iter = max_iter)` (a `max_iter` given to " + - "`solve!` is silently DROPPED on that path — the solve would quietly run 20 outer iterations), and derives " + - "`inf_pr`/`inf_du` on older Altissimo builds. " + - "Also TELL THEM that live iterations depend on the INSTALLED version. " + - "Current Piccolissimo main accepts a `callback` on `solve!(::AltissimoOptions)` and forwards it to " + - "`Altissimo.optimize!`, which fires it every outer iteration; older builds swallow `kwargs...` and forward " + - "nothing, so an Altissimo run there emits NO AMICODE_ITER lines and the Run Inspector stays dark until the " + - "solve finishes. Do not promise live iterations you have not seen: run it, and if no AMICODE_ITER line " + - "appears in the first iterations, say so plainly rather than implying the solve is stuck. Never switch to " + - "Altissimo silently. " + + "**COPY THE TEMPLATE. Do not write a solve script from memory.** Read the template file and edit its " + + "FILL-IN parameters — that is the whole authoring step. Every cloud solve that has failed so far failed " + + "this way: the script was authored from scratch and invented API that does not exist. Real examples, all " + + "from ONE script (2026-08-05, task 975a7c07): " + + "`CubicSplinePulse(; T=…, n_knots=…, n_drives=…, bounds=…)` — every real method takes POSITIONAL " + + "arguments, so an all-keyword call matches nothing; `CallbackLogger(qcp)` — not defined in Piccolo or " + + "Piccolissimo; `get_fidelity(qcp)` — not defined either. That script died with a MethodError at LOAD " + + "time, before any optimization, after the user had already paid the full queue and instance-boot wait. " + + "If you need a name the template does not already use, verify it against the package instead of " + + "guessing: a plausible-looking name you have not checked is the most expensive mistake available on " + + "this tier. " + + "**Solver backend — already decided, do not touch it.** The template you copy arrives with its " + + "`SOLVER` line ALREADY SET to Altissimo, because Piccolissimo + Altissimo is the selected solver. " + + "Do NOT edit that line, and do NOT hand-write the `solve!` call. The template wires Altissimo's " + + "`(x, info)` callback for both telemetry channels (frames AND `AMICODE_ITER` — the frames come off " + + "`IpoptOptions.intermediate_callback`, which `AltissimoOptions` has no equivalent of, so a hand-rolled " + + "call loses the Inspector's plots as well as its numbers), passes the budget as " + + "`AltissimoOptions(max_outer_iter = max_iter)` (a `max_iter` given to `solve!` is silently DROPPED on " + + "that path and the solve quietly runs 20 outer iterations), and bridges the DirectTrajOpt 0.9.7 " + + "extension-point rename so the backend actually dispatches. " + + "**Report what the artifacts say, never what the exit code says.** A `completed` / exit-0 run can still " + + "have optimized NOTHING: read `result.toml` and if `iterations` is 0 — or the template emitted " + + "`AMICODE_WARN` — the fidelity is the random initial guess, so say the run did not converge. Do not call " + + "a 0-iteration run successful. " + routing + "\n" ); @@ -356,7 +374,7 @@ export function solverModeSection(): string { * before. The estimate SUGGESTS the confirm's default; the researcher always * confirms, per-solve, never auto-routed. */ export function routingSection(): string { - return buildRoutingSection(readRoutingContext(readSolverModeState().mode)); + return buildRoutingSection(readRoutingContext(effectiveSolverMode())); } /** The model pin to inject into the generated config, or undefined. @@ -523,9 +541,32 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro const raw = fs.existsSync(opts.agentsSrc) ? fs.readFileSync(opts.agentsSrc, "utf8") : "# Amicode\nRead the template at {{TEMPLATE_PATH}}, fill params, run `amico-run