Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/amico-run/src/authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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,
},
};
}
98 changes: 83 additions & 15 deletions packages/amico-run/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,6 +44,7 @@ export async function launch(argv: string[]): Promise<number> {
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];
Expand All @@ -59,6 +61,7 @@ export async function launch(argv: string[]): Promise<number> {
return 0;
case "--executor":
executor = next();
executorExplicit = true;
break;
case "--lab":
opts.lab = next();
Expand Down Expand Up @@ -126,25 +129,52 @@ export async function launch(argv: string[]): Promise<number> {
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
Expand All @@ -155,6 +185,44 @@ export async function launch(argv: string[]): Promise<number> {
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
Expand Down
28 changes: 28 additions & 0 deletions packages/amico-run/src/solver_mode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { readAuthoring } from "./authoring.js";
import { homedir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -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");
}
15 changes: 14 additions & 1 deletion packages/amico-run/src/subcommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,20 @@ export function resolveCommand(argv: string[]): number {
const out: Record<string, unknown> = { 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 };
Expand Down
31 changes: 18 additions & 13 deletions packages/amico-run/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..") });
Expand All @@ -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");
Expand All @@ -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",
Expand All @@ -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: "",
Expand All @@ -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/);
});
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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`);
Expand All @@ -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`);
Expand Down Expand Up @@ -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`);
Expand All @@ -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() },
Expand Down
Loading
Loading