diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 41fbf2be1..c26d260a4 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -9,13 +9,19 @@ import { registerRunInspector, revealInspector } from "./run_inspector"; import { registerCatalogCard } from "./catalog_card_shell"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, resolveModelPin } from "./opencode_config"; +import { + prepareOpencodeProject, + resolveJuliaProject, + buildOpencodeConfigContent, + resolveModelPin, +} from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; 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 { amicodeOpsDir } from "./substrate/vault_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; import * as os from "node:os"; @@ -148,7 +154,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }; const opencodeProject = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), - templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"), + // 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", + ), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), platformSkills: cfgArr("platformSkills"), @@ -231,6 +244,66 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); + // 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 + // the entitlement), restart the server, and only then report ready (the + // watcher writes it). The app's switch wizard polls through the gap. + ctx.subscriptions.push( + watchSolverMode(async (mode) => { + opencodeChannel.appendLine(`[solver] switching → ${mode}`); + applyEntitlementForMode(mode, path.join(os.homedir(), ".amico", "amicode")); + const project2 = prepareOpencodeProject({ + 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", + ), + juliaProject: resolveJuliaProject( + vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), + ), + skillRoots: cfgArr("skillRoots"), + platformSkills: cfgArr("platformSkills"), + skillLibraryRoots: cfgArr("skillLibraryRoots"), + vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, + }); + await serverManager?.stop(); + serverManager = new ServerManager({ + binary: binary!, + cwd: project2.projectDir, + port: configuredPort > 0 ? configuredPort : undefined, + env: { + PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + project2.agentsPath, + project2.templatePath, + runsRoot, + undefined, + undefined, + project2.skillPaths, + project2.skillsStageDir, + project2.vaultDir, + resolveModelPin(), + ), + }, + channel: opencodeChannel, + }); + serverManager.onReady((url) => { + opencodeReadyUrl = url; + }); + await serverManager.start(); + opencodeChannel.appendLine(`[solver] switched → ${mode} (server back up)`); + void vscode.window.showInformationMessage( + mode === "hp" + ? "Amicode: High-Performance Solver active (Piccolissimo unlocked)." + : "Amicode: back on the Piccolo stack.", + ); + }), + ); + // Distiller transport (spec-20260705-002847 §4): written once per // activation so every spawner — the run-finished trigger here, the plugin's // onboarding trigger, and the batch shell — produces identical headless diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index a1806a680..05564b0d4 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -13,6 +13,7 @@ import { stageOpencodeSkills, type SkillIndexEntry, } from "./scores/package_skills"; +import { readSolverModeState } from "./solver_mode"; import { resolvePersonalVault, defaultVaultsRoot, @@ -262,6 +263,21 @@ export function preferredModel( return "opencode/deepseek-v4-flash-free"; } +/** Solver-mode section for AGENTS.md (rchari/solver-wire): in HP mode the + * agent authors with the Piccolissimo stack by default — the entitlement + * gate (issimo) has already admitted the packages by the time this renders. */ +function solverModeSection(): string { + if (readSolverModeState().mode !== "hp") return ""; + return ( + "\n\n## Solver mode\n" + + "**HIGH-PERFORMANCE (Piccolissimo + Altissimo).** The user selected the HP solver. " + + "Author solves with the **Piccolissimo** stack by default (SplinePulseProblem, free-phase paths, " + + "`using Piccolissimo`) rather than plain Piccolo, falling back to Piccolo only when Piccolissimo " + + "cannot express the problem (say so when you do). The Altissimo GPU executor is provisioned but " + + "dispatches locally until the cloud endpoint ships — never claim cloud execution.\n" + ); +} + /** The model pin to inject, or undefined. FALLBACK-only: a model in the user's * global opencode config wins (our injected config would override it in the * merge — the 1.17.3 preserve-user-model contract), so we pin nothing then. */ @@ -481,7 +497,7 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro console.warn(`amicode: skill index failed (session continues without it): ${e}`); } - fs.writeFileSync(agentsPath, finalContent, "utf8"); + fs.writeFileSync(agentsPath, finalContent + solverModeSection(), "utf8"); // spec C: write the authoring.json seam amico-run reads (allowlist resolved // from the same entitlements the score filter used + the bundled asset paths). @@ -517,7 +533,7 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro } } - fs.writeFileSync(agentsPath, finalContent, "utf8"); + fs.writeFileSync(agentsPath, finalContent + solverModeSection(), "utf8"); // The agent reads the template from its bundled absolute path (the session // cwd is the workspace, not this temp dir — so no copy is made here). diff --git a/packages/extension/src/solver_mode.ts b/packages/extension/src/solver_mode.ts new file mode 100644 index 000000000..be642531e --- /dev/null +++ b/packages/extension/src/solver_mode.ts @@ -0,0 +1,96 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { amicodeOpsDir } from "./substrate/vault_store"; + +// ============================================================================ +// Solver mode — the extension half of the solver-mode.json contract (the fork +// half is packages/opencode/src/server/amicode/solver-mode.ts; change both in +// one change-set). The app's toggle POSTs {mode, status:"switching"}; WE do +// the real switch — grant/revoke the `issimo` entitlement, re-prep the session +// config, restart the opencode server — and only then write status:"ready". +// The entitlement path is deliberate: it exercises the SAME machinery a paid +// subscription will use (packageAllowlist → agent + amico-run import scan). +// ============================================================================ + +export type SolverMode = "piccolo" | "hp"; +export interface SolverModeState { + mode: SolverMode; + status: "ready" | "switching"; +} + +export function solverModeFile(opsDir: string = amicodeOpsDir()): string { + return path.join(opsDir, "solver-mode.json"); +} + +export function readSolverModeState(file: string = solverModeFile()): SolverModeState { + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { mode?: unknown; status?: unknown }; + return { + mode: parsed.mode === "hp" ? "hp" : "piccolo", + status: parsed.status === "switching" ? "switching" : "ready", + }; + } catch { + return { mode: "piccolo", status: "ready" }; + } +} + +export function writeSolverModeReady(mode: SolverMode, file: string = solverModeFile()): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ mode, status: "ready", switched_at: new Date().toISOString() })); +} + +/** Grant (hp) or revoke (piccolo) the `issimo` entitlement, PRESERVING any + * other codes in entitlements.toml (a real license file may carry more). */ +export function applyEntitlementForMode(mode: SolverMode, entitlementsDir: string): { codes: string[] } { + const file = path.join(entitlementsDir, "entitlements.toml"); + let codes: string[] = []; + let expired: string[] = []; + try { + const parsed = parseToml(fs.readFileSync(file, "utf8")) as { codes?: string[]; expired?: string[] }; + codes = Array.isArray(parsed.codes) ? parsed.codes.filter((c) => typeof c === "string") : []; + expired = Array.isArray(parsed.expired) ? parsed.expired.filter((c) => typeof c === "string") : []; + } catch { + /* absent/corrupt → start empty */ + } + codes = codes.filter((c) => c !== "issimo"); + if (mode === "hp") codes.push("issimo"); + fs.mkdirSync(entitlementsDir, { recursive: true }); + const lines = [`codes = [${codes.map((c) => JSON.stringify(c)).join(", ")}]`]; + if (expired.length > 0) lines.push(`expired = [${expired.map((c) => JSON.stringify(c)).join(", ")}]`); + fs.writeFileSync(file, lines.join("\n") + "\n"); + return { codes }; +} + +/** Poll-based watcher (fs.watch is unreliable for rewrite-in-place): fires + * onSwitch once per switching-request. The busy latch guarantees a slow + * switch is never re-entered by the next tick. */ +export function watchSolverMode( + onSwitch: (mode: SolverMode) => Promise, + file: string = solverModeFile(), + intervalMs = 1000, +): { dispose(): void } { + let busy = false; + const timer = setInterval(() => { + if (busy) return; + const state = readSolverModeState(file); + if (state.status !== "switching") return; + busy = true; + void onSwitch(state.mode) + .then(() => writeSolverModeReady(state.mode, file)) + .catch(() => { + // Failed switch: report ready-at-PREVIOUS-mode is a lie we can't tell + // (we don't know it), so settle at the requested mode anyway — config + // may be partially applied; the toast in extension.ts says what failed. + writeSolverModeReady(state.mode, file); + }) + .finally(() => { + busy = false; + }); + }, intervalMs); + return { + dispose() { + clearInterval(timer); + }, + }; +} diff --git a/packages/extension/templates/solve_template_hp.jl b/packages/extension/templates/solve_template_hp.jl new file mode 100644 index 000000000..ac58c9ec3 --- /dev/null +++ b/packages/extension/templates/solve_template_hp.jl @@ -0,0 +1,169 @@ +#!/usr/bin/env julia +# Amicode HP solve template (Piccolissimo) — fill in the `# FILL IN` block, then: +# amico-run --project solve.jl +# Emits the SAME run-dir contract as solve_template.jl (AMICODE_ITER, iter_.png, +# AMICODE_PULSE_META/AMICODE_PULSE, STOP-file cooperative stop, result.toml, +# pulse.jld2, DONE) — the inspector/run-card readers cannot tell the modes apart. +# HP swap = the SOLVER LAYER: Piccolissimo's continuous-time `SplineIntegrator` +# (true cubic-spline dynamics via ODE propagation with analytic sensitivities) +# inside Piccolo's `SplinePulseProblem`, instead of the piecewise-constant +# ZeroOrderPulse + SmoothPulseProblem bilinear path. The optimizer stays Ipopt: +# AMICODE_ITER needs the raw IPM state (obj/inf_pr/inf_du) and the STOP poll +# rides Ipopt's intermediate callback — Altissimo exposes neither (Q74). +using Piccolissimo # HP layer: SplineIntegrator, Tsit5Alg (composes with Piccolo) +using Piccolo +using CairoMakie # loads PiccoloMakieExt → gives LivePulsePlotCallback its impl +using JLD2 +using TOML +using Printf + +# ── FILL IN ────────────────────────────────────────────────────────────── +δ = 0.2 # anharmonicity (GHz, positive convention) +levels = 3 # transmon levels modeled (3 = qubit + 1 leakage; bump to 4–5 for more leakage realism) +gate = GATES[:X] +T = 10.0 # gate time (ns) +N = 50 # timesteps +drive_max = 0.2 # per-quadrature drive bound (GHz) +max_iter = 60 +# ───────────────────────────────────────────────────────────────────────── + +sys = TransmonSystem(; δ = δ, levels = levels, drive_bounds = fill(drive_max, 2)) +op = size(gate, 1) == sys.levels ? gate : EmbeddedOperator(gate, sys) + +times = collect(range(0.0, T, length = N)) +initial = 0.1 * randn(sys.n_drives, N) +# Cubic Hermite spline pulse: knot values AND tangents (:u, :du) are the decision +# variables (tangents init to zero). This is the pulse family Piccolissimo's +# spline integrator propagates exactly. +qtraj = UnitaryTrajectory(sys, CubicSplinePulse(initial, times), op) +# Piccolissimo's continuous-time integrator: per-interval ODE propagation of the +# actual spline (Tsit5, adaptive), with analytic sensitivity equations for the +# Jacobian — vs the default BilinearIntegrator's piecewise-constant model. +integrator = SplineIntegrator(qtraj, N; alg = Tsit5Alg()) +qcp = SplinePulseProblem(qtraj, N; + integrator = integrator, + piccolo_options = PiccoloOptions(timesteps_all_equal = true), + Q = 100.0, R = 1e-2) +prob = hasproperty(qcp, :prob) ? qcp.prob : qcp + +# Per-iter live plot flows through Piccolo's `LivePulsePlotCallback`, an +# `AbstractIntermediateCallback` (the blessed, solver-agnostic per-iter plot +# idiom — see AGENTS.md). It reconstructs the pulse from the optimizer's primal +# each iteration and writes `iter_.png` into the run dir; the Run Inspector +# reads those frames. `every` is the redraw cadence. (No hand-rolled plotting: +# the PNGs are the callback's job, not the script's. `extract_pulse` has a +# CubicSplinePulse method, so the frames render the true spline.) +const PLOT_EVERY = 6 +live_plot = LivePulsePlotCallback(qtraj, prob.trajectory; every = PLOT_EVERY, save_dir = ".") + +# Pulse-data telemetry (#66, prototype-grade): raw knot values per iteration as +# AMICODE_PULSE lines on stdout (→ run.log), riding the SAME solver-agnostic +# (primal, iter) hook as the live plot — the inspector renders them natively. +# Additive to the run-dir contract: consumers that don't know the lines ignore +# them. META once (shape + bounds), then one record per iteration (~1KB). +struct PulseEmitCallback <: AbstractIntermediateCallback + inner::Any # delegate (the live plot) — fires first, keeps the PNG cadence + traj::Any # prob.trajectory — synced from the primal, then read +end +function (cb::PulseEmitCallback)(primal, iter) + # Cooperative stop: the Run Inspector's Stop button drops a STOP file into the + # run dir (== cwd). Returning false from Ipopt's intermediate_callback halts + # the solve (User_Requested_Stop) at the next iteration; solve! returns + # normally, so the partial pulse.jld2/result.toml still get written below. + if isfile("STOP") + println("AMICODE_STOPPED"); flush(stdout) + return false + end + ok = cb.inner(primal, iter) + try + traj = cb.traj + expected = traj.dim * traj.N + traj.global_dim + if length(primal) == expected + # Own sync — the delegate only updates the trajectory on its plot cadence. + # Qualified: `update!` is also exported by Makie/CairoMakie — the + # unqualified binding is ambiguous once the plotting stack loads. + if traj.global_dim > 0 + Piccolo.NamedTrajectories.update!(traj, collect(view(primal, 1:expected)); type = :both) + else + Piccolo.NamedTrajectories.update!(traj, collect(view(primal, 1:(traj.dim * traj.N))); type = :data) + end + # Drive component name differs by problem flavor (:u current, :a + # legacy). Membership check (not `something(traj.u, traj.a)`): it + # keeps the fallback reachable without leaning on property access + # returning `nothing` for missing components (review nit, #67). + A = :u in traj.names ? traj.u : (:a in traj.names ? traj.a : missing) + A === missing && error("no drive component (:u/:a) on trajectory") + vals = join((join((@sprintf("%.6g", v) for v in row), ",") for row in eachrow(A)), ";") + @printf("AMICODE_PULSE iter=%d dt=%.6g a=%s\n", iter, first(Piccolo.get_timesteps(traj)), vals) + flush(stdout) + end + catch e + @warn "pulse emit failed" exception = e maxlog = 3 # never let telemetry kill the solve + end + return ok +end +pulse_emit = PulseEmitCallback(live_plot, prob.trajectory) + +let ls = join(("\"a_$i\"" for i in 1:sys.n_drives), ","), + bs = join(("$(-drive_max):$(drive_max)" for _ in 1:sys.n_drives), ",") + println("AMICODE_PULSE_META drives=$(sys.n_drives) knots=$N labels=$ls bounds=$bs") + flush(stdout) +end + +# AMICODE_ITER text telemetry stays on the RAW Ipopt callback — it needs the rich +# IPM state (obj_value/inf_pr/inf_du) that the agnostic `(primal, iter)` contract +# doesn't carry. Both callbacks fire once per iteration (DTO composes the raw +# callback with `intermediate_callback`); the live inspector is ipopt-only (Q74). +const CB = Piccolo.Callbacks +iters = Ref(0) +function cb_log(optimizer, st; kwargs...) + k = Int(st.iter_count); iters[] = k + @printf("AMICODE_ITER iter=%d f=%.6e inf_pr=%.3e inf_du=%.3e\n", k, st.obj_value, st.inf_pr, st.inf_du) + flush(stdout) + return true +end + +t0 = time() +solve!(qcp; max_iter = max_iter, print_level = 1, + options = IpoptOptions(intermediate_callback = pulse_emit), + callback = CB.callback_factory(cb_log)) +wall = time() - t0 + +# Fidelity over the COMPUTATIONAL subspace, from a fresh high-tolerance rollout. +# Two reasons this is the right metric: +# - subspace (not full-space): the embedded goal pins identity on the leakage +# level, which the solve doesn't enforce — full-space would read ~0.44 even +# for a perfect qubit gate. We want the gate fidelity on {|0>,|1>}. +# - rollout (not the raw final propagator): re-integrating at 1e-8 yields a +# clean unitary, avoiding the ~1e-6 norm-drift that made the raw block read >1. +# `interpolation = :cubic` reconstructs the Hermite spline from (:u, :du) — the +# rollout integrates the SAME pulse the spline integrator optimized. +Uroll = iso_vec_to_operator(unitary_rollout(get_trajectory(qcp), sys; interpolation = :cubic)[:, end]) +fid = unitary_fidelity(Uroll, op.operator; subspace = op.subspace) + +# End-of-solve guarantee frame — STILL through LivePulsePlotCallback (no bespoke +# plot). The live callback fires at iters 0, PLOT_EVERY, 2·PLOT_EVERY, …; a solve +# that converges in < PLOT_EVERY iters would otherwise leave only the iter-0 +# random-init frame (inspector stuck showing the initial guess). Re-invoke the +# callback once at every=1 with the FINAL primal so the last frame is the +# converged pulse. prob.trajectory is the final iterate here (DTO synced it after +# solve!), so this reconstructs the same primal the callback saw per-iter. +let final_cb = LivePulsePlotCallback(qtraj, prob.trajectory; every = 1, save_dir = ".") + tr = prob.trajectory + final_primal = tr.global_dim > 0 ? vcat(collect(tr.datavec), collect(tr.global_data)) : collect(tr.datavec) + final_cb(final_primal, iters[]) +end + +JLD2.save("pulse.jld2", "traj", prob.trajectory) # key "traj" so `load_traj` can reload it (warm-start) +open("result.toml.tmp", "w") do io + # Record the regime each run actually solved (scalar FILL-IN params), so the + # result is self-describing — not just fidelity/iterations. + TOML.print(io, Dict( + "schema_version" => "1", # run-dir contract version (@amicode/schema result schema) + "fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall, + "params" => Dict("delta" => δ, "levels" => levels, "T" => T, "N" => N, + "drive_max" => drive_max, "max_iter" => max_iter), + )) +end +mv("result.toml.tmp", "result.toml"; force = true) +println("DONE fidelity=$(fid)"); flush(stdout) diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 7e0080209..6c4e9032d 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -8,6 +8,7 @@ const REQUIRED = [ "extension/bin/dist/amico-run.js", "extension/bin/launcher/amico-run", "extension/templates/solve_template.jl", + "extension/templates/solve_template_hp.jl", // HP-mode vetted template (Piccolissimo) — mode-selected at prep time // spec C authoring assets — the tiered resolver + verification chain break // silently if any of these is dropped from the vsix. "extension/templates/registry.toml", // tier-1 template registry + support set + sandbox uuid map diff --git a/packages/extension/test/solver_mode.test.ts b/packages/extension/test/solver_mode.test.ts new file mode 100644 index 000000000..4cab38ad9 --- /dev/null +++ b/packages/extension/test/solver_mode.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + readSolverModeState, + writeSolverModeReady, + applyEntitlementForMode, + watchSolverMode, + solverModeFile, +} from "../src/solver_mode"; + +describe("solver mode state", () => { + it("defaults piccolo/ready; ready write round-trips", () => { + const f = join(mkdtempSync(join(tmpdir(), "sm-")), "solver-mode.json"); + expect(readSolverModeState(f)).toEqual({ mode: "piccolo", status: "ready" }); + writeSolverModeReady("hp", f); + expect(readSolverModeState(f)).toEqual({ mode: "hp", status: "ready" }); + }); +}); + +describe("applyEntitlementForMode", () => { + it("hp grants issimo, piccolo revokes it, OTHER codes preserved both ways", () => { + const dir = mkdtempSync(join(tmpdir(), "ents-")); + writeFileSync(join(dir, "entitlements.toml"), 'codes = ["pasqal-hackathon-2026"]\n'); + expect(applyEntitlementForMode("hp", dir).codes).toEqual(["pasqal-hackathon-2026", "issimo"]); + expect(readFileSync(join(dir, "entitlements.toml"), "utf8")).toContain('"issimo"'); + expect(applyEntitlementForMode("piccolo", dir).codes).toEqual(["pasqal-hackathon-2026"]); + expect(readFileSync(join(dir, "entitlements.toml"), "utf8")).not.toContain("issimo"); + }); + it("idempotent grant (no duplicate issimo); creates the file from nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "ents-")); + applyEntitlementForMode("hp", dir); + expect(applyEntitlementForMode("hp", dir).codes).toEqual(["issimo"]); + }); +}); + +describe("watchSolverMode", () => { + it("fires once per switching request, then settles ready; busy latch holds", async () => { + const f = join(mkdtempSync(join(tmpdir(), "sm-")), "solver-mode.json"); + const calls: string[] = []; + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const w = watchSolverMode( + async (m) => { + calls.push(m); + await gate; + }, + f, + 20, + ); + writeFileSync(f, JSON.stringify({ mode: "hp", status: "switching" })); + await new Promise((r) => setTimeout(r, 120)); // several ticks while busy + expect(calls).toEqual(["hp"]); // latch held — no re-entry + release(); + await new Promise((r) => setTimeout(r, 80)); + expect(readSolverModeState(f)).toEqual({ mode: "hp", status: "ready" }); + w.dispose(); + }); +});