From 24b0d88f1712e74a4a0e46895777b141f6db0406 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 11:03:18 -0400 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20prebuilt=20sysimage=20to=20collap?= =?UTF-8?q?se=20the=20solve=20cold-start=20(~100s=20=E2=86=92=20seconds)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosed the run-orchestrator lag: it's all cold-start — ~80s Julia/Piccolo load + first-iter JIT, plus a ~35s first plot_pulse (Makie compiles its render stack once). Steady state is fine (~1.5-3s/iter, 0.2-0.9s plots). Fix: a PackageCompiler sysimage baking Piccolo + CairoMakie + the solve/plot code paths (build_sysimage.jl + sysimage_exercise.jl). install.sh builds it best-effort at /amico-sysimage.{dylib,so}; amico-run auto-detects a sysimage beside --project and passes --sysimage (no agent/flag change). Falls back cleanly to no-sysimage (slow first run) if the build is skipped/fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/amico-run/src/cli.ts | 11 ++++++ packages/amico-run/test/cli.test.ts | 12 +++++++ packages/extension/julia/build_sysimage.jl | 27 ++++++++++++++ packages/extension/julia/sysimage_exercise.jl | 36 +++++++++++++++++++ packages/extension/scripts/install.sh | 18 ++++++++++ 5 files changed, 104 insertions(+) create mode 100644 packages/extension/julia/build_sysimage.jl create mode 100644 packages/extension/julia/sysimage_exercise.jl diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index b00ddfbc3..395d8a477 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -40,6 +40,17 @@ export async function main(argv: string[]): Promise { if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 } if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 } + // Auto-detect a prebuilt sysimage next to the project when one wasn't passed. + // A sysimage with Piccolo + CairoMakie baked in collapses the ~100s cold start + // (Julia/Piccolo load + Makie's first-plot compile) to a few seconds. install.sh + // builds it at `/amico-sysimage.{dylib,so}`; agents need no flag change. + if (!opts.julia!.sysimage && opts.julia!.project) { + for (const ext of ['dylib', 'so']) { + const cand = join(opts.julia!.project, `amico-sysimage.${ext}`) + if (existsSync(cand)) { opts.julia!.sysimage = cand; break } + } + } + let handle try { handle = await new LocalExecutor().submit(script, opts) diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index 892cc966e..ecefbe8fc 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest' import { execFileSync, execFile } from 'node:child_process' +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpRoot, fakeJulia } from './helpers.js' @@ -47,6 +48,17 @@ describe('amico-run CLI', () => { expect(r.code).toBe(64) expect(r.stderr).toMatch(/unknown flag/) }) + it('auto-detects a sysimage beside --project (no --sysimage flag) → manifest records it', () => { + const root = tmpRoot() + const project = join(root, 'proj'); mkdirSync(project, { recursive: true }) + const sysimg = join(project, 'amico-sysimage.dylib'); writeFileSync(sysimg, 'FAKE') + const julia = fakeJulia(root, 'j', `console.log('DONE f=0.99')`) + const script = fakeJulia(root, 's.jl', '') + const runsRoot = join(root, 'runs') + const r = run([script, '--runs-root', runsRoot, '--julia', julia, '--project', project]) + expect(r.code).toBe(0) + expect(readFileSync(join(runsRoot, 'latest', 'manifest.toml'), 'utf8')).toContain(sysimg) + }) it('--executor remote → 64 (only local in β)', () => { const root = tmpRoot() const r = run([fakeJulia(root, 's.jl', ''), '--executor', 'remote']) diff --git a/packages/extension/julia/build_sysimage.jl b/packages/extension/julia/build_sysimage.jl new file mode 100644 index 000000000..5a6e6fb99 --- /dev/null +++ b/packages/extension/julia/build_sysimage.jl @@ -0,0 +1,27 @@ +# Build the amico sysimage: bake Piccolo + CairoMakie (+ JLD2/TOML/Printf) and +# the solve/plot code paths into a native image so amico-run starts a solve in +# seconds instead of paying ~100s of Julia/Makie compilation on the first run. +# +# Run with PackageCompiler available (install.sh adds it to the global env): +# AMICO_JULIA_PROJECT=~/.amico/julia julia build_sysimage.jl +# +# Output: /amico-sysimage.{dylib|so} — amico-run auto-detects it. +using PackageCompiler + +const PROJECT = get(ENV, "AMICO_JULIA_PROJECT", joinpath(homedir(), ".amico", "julia")) +const HERE = @__DIR__ +const SYSIMG = joinpath(PROJECT, Sys.isapple() ? "amico-sysimage.dylib" : "amico-sysimage.so") +const EXERCISE = joinpath(HERE, "sysimage_exercise.jl") + +@info "building amico sysimage" project=PROJECT output=SYSIMG +# Only direct, non-stdlib project deps go in the package list; Printf et al. are +# pure stdlibs (always in the sysimage) and their methods get baked via the +# precompile-execution trace. cpu_target left at PackageCompiler's default +# (native to the build machine, which is where install.sh runs it). +create_sysimage( + [:Piccolo, :CairoMakie, :JLD2, :TOML]; + project = PROJECT, + sysimage_path = SYSIMG, + precompile_execution_file = EXERCISE, +) +@info "sysimage built" path=SYSIMG bytes=(isfile(SYSIMG) ? filesize(SYSIMG) : 0) diff --git a/packages/extension/julia/sysimage_exercise.jl b/packages/extension/julia/sysimage_exercise.jl new file mode 100644 index 000000000..1ac47ab48 --- /dev/null +++ b/packages/extension/julia/sysimage_exercise.jl @@ -0,0 +1,36 @@ +# Precompile-execution workload for the amico sysimage build. Exercises exactly +# the code paths a real solve hits — TransmonSystem / EmbeddedOperator / +# SmoothPulseProblem / solve! (Ipopt) / plot_pulse (Makie render+save) / rollout +# / unitary_fidelity — so PackageCompiler bakes their compiled methods into the +# sysimage. That removes the ~80s first-iter JIT and the ~35s first-plot compile. +# Kept tiny (few iters) — we want coverage, not convergence. +using Piccolo +using CairoMakie +using JLD2 +using TOML +using Printf + +try + sys = TransmonSystem(; δ = 0.2, levels = 3, drive_bounds = fill(0.2, 2)) + op = EmbeddedOperator(GATES[:X], sys) + times = collect(range(0.0, 10.0, length = 20)) + qtraj = UnitaryTrajectory(sys, ZeroOrderPulse(0.1 * randn(sys.n_drives, 20), times), op) + qcp = SmoothPulseProblem(qtraj, 20; + piccolo_options = PiccoloOptions(timesteps_all_equal = true), Q = 100.0, R = 1e-2) + solve!(qcp; max_iter = 3, print_level = 0) + + # plot path (the expensive first-render compile we most want baked) + fig = plot_pulse(qcp; bounds = true) + CairoMakie.save(tempname() * ".png", fig) + + # rollout + subspace fidelity (the result metric) + JLD2/TOML serialization + Uroll = iso_vec_to_operator(unitary_rollout(get_trajectory(qcp), sys)[:, end]) + unitary_fidelity(Uroll, op.operator; subspace = op.subspace) + mktempdir() do d + JLD2.save(joinpath(d, "p.jld2"), "traj", qcp isa Any ? get_trajectory(qcp) : nothing) + open(joinpath(d, "r.toml"), "w") do io; TOML.print(io, Dict("fidelity" => 0.99)); end + end + @info "sysimage exercise complete" +catch e + @warn "sysimage exercise hit an error (sysimage still builds; coverage may be partial)" exception = e +end diff --git a/packages/extension/scripts/install.sh b/packages/extension/scripts/install.sh index de1b7000d..f89718831 100755 --- a/packages/extension/scripts/install.sh +++ b/packages/extension/scripts/install.sh @@ -21,6 +21,24 @@ cp "$EXT_ROOT/julia/Manifest.toml" "$JULIA_PROJECT/Manifest.toml" say "instantiating Piccolo project at $JULIA_PROJECT (first run precompiles - be patient)..." julia --project="$JULIA_PROJECT" -e 'using Pkg; Pkg.instantiate()' || die "Pkg.instantiate failed (see Julia error above)" +# 2b. Build the sysimage (bakes Piccolo + CairoMakie + solve/plot paths → first +# solve starts in seconds instead of ~100s of JIT). Best-effort: if it fails the +# lab still works, just with a slow first run, and amico-run runs without it. +SYSIMG_DYLIB="$JULIA_PROJECT/amico-sysimage.dylib"; SYSIMG_SO="$JULIA_PROJECT/amico-sysimage.so" +if [ -f "$SYSIMG_DYLIB" ] || [ -f "$SYSIMG_SO" ]; then + say "sysimage already present - skipping (delete it to rebuild)" +elif [ "${AMICO_SKIP_SYSIMAGE:-0}" = "1" ]; then + say "AMICO_SKIP_SYSIMAGE=1 - skipping sysimage build (first solve will be slow)" +else + say "building sysimage (one-time, ~5-15 min; removes the cold-start lag)..." + if julia -e 'using Pkg; Pkg.add("PackageCompiler")' \ + && AMICO_JULIA_PROJECT="$JULIA_PROJECT" julia "$EXT_ROOT/julia/build_sysimage.jl"; then + say "sysimage built - amico-run will auto-detect it" + else + say "WARNING: sysimage build failed - the lab still works, first solve will just be slow (re-run install.sh to retry)" + fi +fi + # 3. Install the VSIX if command -v code >/dev/null 2>&1; then [ -f "$VSIX" ] || die "VSIX not found at $VSIX - build it: pnpm --filter amicode-v2 package" From d98e5c897937c31f273241a654f8fd3a5d9488eb Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 11:03:18 -0400 Subject: [PATCH 02/10] feat(inspector): warming-up state + skip iter-0 plot (smoother cold start) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inspector shows 'Julia warming up — first solve compiles (~1-2 min)' when a run has started but emitted no frame yet, instead of an idle panel, so the cold start doesn't read as frozen. Buffered like the image/completion so it shows even if the panel opens late; replaced by the first frame. - Template skips the iter-0 plot (just the random init) — defers Makie's first-plot compile off the very first iteration when there's no sysimage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/src/file_watcher.ts | 5 +++++ packages/extension/src/inspector_webview.ts | 10 +++++++++ packages/extension/src/run_inspector.ts | 22 ++++++++++++++++++- .../extension/templates/solve_template.jl | 2 +- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index beea80d2a..b307c5dea 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -154,6 +154,11 @@ export class RunsRootWatcher implements vscode.Disposable { catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } this.finishedSeen = finishedAtSwitch; + // Fresh run (manifest but no frames/FINISHED yet) → Julia/Makie warming up; + // show that instead of an idle panel so the ~minute cold start isn't read as frozen. + const hasFrame = fs.readdirSync(runDir).some((f) => ITER_PNG_RE.test(f)); + if (!finishedAtSwitch && !hasFrame) getInspector()?.setWarmingUp(); + // Incremental: new iter PNGs + FINISHED. this.activeRunWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => { if (!filename) return; diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index d34edf3eb..a703967e5 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -36,6 +36,16 @@ window.addEventListener("message", (e) => { setBadge("running", "running"); break; } + case "warming": { + // A run started but Julia/Makie are still compiling — show that instead of + // an idle panel, so the cold start doesn't read as frozen. + const ph = document.getElementById("placeholder"); + const hint = document.getElementById("m-hint"); + if (hint) hint.textContent = "Julia warming up — the first solve compiles Piccolo + the plotter (~1–2 min). Frames will stream here."; + if (ph) ph.hidden = false; + setBadge("running", "warming up"); + break; + } case "completed": { // Authoritative terminal state from the watcher (FINISHED on disk). const ok = msg.status === "completed"; diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 94e41b1c1..38f027cb0 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -26,6 +26,9 @@ class InspectorView implements vscode.WebviewViewProvider { * watcher follows `latest` → a finished run completes before the panel is * opened). Replayed after the buffered image so the badge isn't stuck "running". */ private bufferedCompletion?: { status: string; fidelity?: number }; + /** A run started but hasn't emitted its first frame yet (Julia warming up). + * Buffered so the warming state shows even if the panel opens late. */ + private bufferedWarming = false; constructor(private readonly ctx: vscode.ExtensionContext, private readonly runsRoot: string) {} @@ -45,6 +48,11 @@ class InspectorView implements vscode.WebviewViewProvider { view.webview.html = this.renderHtml(view.webview); view.onDidDispose(() => { this.view = undefined; this.clearTimer(); }); + // A run is warming up (no frame yet) — show that until the first frame. + if (this.bufferedWarming && !this.bufferedImage) { + this.bufferedWarming = false; + view.webview.postMessage({ type: "warming" }); + } // Replay the most recent pending image once the webview is alive. if (this.bufferedImage) { this.pendingRefresh = this.bufferedImage; @@ -114,6 +122,18 @@ class InspectorView implements vscode.WebviewViewProvider { this.view.webview.postMessage({ type: "completed", status, fidelity }); } + /** A run started but has no frame yet (Julia/Makie warming up) — show that + * instead of an idle panel, so a ~minute of cold start doesn't read as frozen. + * Replaced by the first frame (the refresh handler hides the placeholder). */ + setWarmingUp(): void { + if (!this.view) { + this.bufferedWarming = true; + vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); + return; + } + this.view.webview.postMessage({ type: "warming" }); + } + reveal(): void { // Force materialize the view via its auto-registered .focus command. // Unconditional — without an existing view, this is what creates one. @@ -226,7 +246,7 @@ class InspectorView implements vscode.WebviewViewProvider { frame preview B
<0||0> - No solve in progress — fire one from the Amicode chat, or run “Replay demo run”. + No solve in progress — fire one from the Amicode chat, or run “Replay demo run”.
diff --git a/packages/extension/templates/solve_template.jl b/packages/extension/templates/solve_template.jl index 2f8ee4417..9b752205f 100644 --- a/packages/extension/templates/solve_template.jl +++ b/packages/extension/templates/solve_template.jl @@ -44,7 +44,7 @@ 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) - (k % PLOT_EVERY == 0) && save_control_plot(k) + (k > 0 && k % PLOT_EVERY == 0) && save_control_plot(k) # skip iter-0 (just the random init; defers Makie's first-plot compile off the first iter) return true end From 0dcb2f262a9b20a9053555b0c4cf1c316195cc27 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 11:53:41 -0400 Subject: [PATCH 03/10] feat(extension): make the sysimage build opt-in, not a blocking install step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PackageCompiler build (CairoMakie native-compile) is a long one-time cost (~25-50 min, machine-dependent) — too slow to sit on the install critical path (β.4's <=60min target). Flip it to opt-in: install.sh builds it only with AMICO_BUILD_SYSIMAGE=1 (else prints the tip), plus a 'pnpm --filter amicode-v2 sysimage' script. amico-run auto-detects it once present; until then solves pay the cold start and the inspector shows 'warming up'. One-time + per-machine (native artifact — not shippable prebuilt). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/package.json | 1 + packages/extension/scripts/install.sh | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 405684032..58f4ff427 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -111,6 +111,7 @@ "test:slow": "vitest run test/slow", "test:smoke": "node test/boot_smoke.mjs", "fetch:opencode": "node scripts/fetch_opencode.mjs", + "sysimage": "julia -e 'using Pkg; Pkg.add(\"PackageCompiler\")' && julia julia/build_sysimage.jl", "healthcheck": "node scripts/healthcheck.mjs", "package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix" }, diff --git a/packages/extension/scripts/install.sh b/packages/extension/scripts/install.sh index f89718831..57b5fe899 100755 --- a/packages/extension/scripts/install.sh +++ b/packages/extension/scripts/install.sh @@ -21,22 +21,26 @@ cp "$EXT_ROOT/julia/Manifest.toml" "$JULIA_PROJECT/Manifest.toml" say "instantiating Piccolo project at $JULIA_PROJECT (first run precompiles - be patient)..." julia --project="$JULIA_PROJECT" -e 'using Pkg; Pkg.instantiate()' || die "Pkg.instantiate failed (see Julia error above)" -# 2b. Build the sysimage (bakes Piccolo + CairoMakie + solve/plot paths → first -# solve starts in seconds instead of ~100s of JIT). Best-effort: if it fails the -# lab still works, just with a slow first run, and amico-run runs without it. +# 2b. OPTIONAL sysimage (bakes Piccolo + CairoMakie + solve/plot paths → first +# solve starts in seconds instead of ~100s of JIT). OPT-IN, not on the install +# critical path: the native compile (CairoMakie-dominated) is a long one-time +# build (~25-50 min, machine-dependent), so we don't block install on it. Build +# it when you want faster solves: `AMICO_BUILD_SYSIMAGE=1 bash install.sh`, or +# `pnpm --filter amicode-v2 sysimage`. amico-run auto-detects it once present; +# until then solves just pay the cold start (the inspector shows "warming up"). SYSIMG_DYLIB="$JULIA_PROJECT/amico-sysimage.dylib"; SYSIMG_SO="$JULIA_PROJECT/amico-sysimage.so" if [ -f "$SYSIMG_DYLIB" ] || [ -f "$SYSIMG_SO" ]; then - say "sysimage already present - skipping (delete it to rebuild)" -elif [ "${AMICO_SKIP_SYSIMAGE:-0}" = "1" ]; then - say "AMICO_SKIP_SYSIMAGE=1 - skipping sysimage build (first solve will be slow)" -else - say "building sysimage (one-time, ~5-15 min; removes the cold-start lag)..." + say "sysimage present - amico-run will auto-detect it (fast solves)" +elif [ "${AMICO_BUILD_SYSIMAGE:-0}" = "1" ]; then + say "building sysimage (one-time, ~25-50 min; CairoMakie native-compile is the long pole)..." if julia -e 'using Pkg; Pkg.add("PackageCompiler")' \ && AMICO_JULIA_PROJECT="$JULIA_PROJECT" julia "$EXT_ROOT/julia/build_sysimage.jl"; then say "sysimage built - amico-run will auto-detect it" else - say "WARNING: sysimage build failed - the lab still works, first solve will just be slow (re-run install.sh to retry)" + say "WARNING: sysimage build failed - the lab still works, first solve just pays the cold start" fi +else + say "tip: for fast solves (no ~2-min cold start), build the sysimage once: AMICO_BUILD_SYSIMAGE=1 bash $EXT_ROOT/scripts/install.sh" fi # 3. Install the VSIX From 8388cfb959613b9496cfe8713b919fface500745 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 11:55:28 -0400 Subject: [PATCH 04/10] fix(inspector): warming-up wording (every cold solve warms up, not just the first) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/src/inspector_webview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index a703967e5..901c08178 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -41,7 +41,7 @@ window.addEventListener("message", (e) => { // an idle panel, so the cold start doesn't read as frozen. const ph = document.getElementById("placeholder"); const hint = document.getElementById("m-hint"); - if (hint) hint.textContent = "Julia warming up — the first solve compiles Piccolo + the plotter (~1–2 min). Frames will stream here."; + if (hint) hint.textContent = "Julia warming up — compiling the solver + plotter (~1–2 min). Frames will stream here."; if (ph) ph.hidden = false; setBadge("running", "warming up"); break; From 2287cadb9c9fc1f2b4bb8f5c09bb4320acf5cf12 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 12:29:54 -0400 Subject: [PATCH 05/10] =?UTF-8?q?fix(inspector):=20poll=20the=20run=20dir?= =?UTF-8?q?=20for=20frames=20=E2=80=94=20don't=20rely=20on=20flaky=20fs.wa?= =?UTF-8?q?tch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live frames/FINISHED were detected only via fs.watch (FSEvents), which coalesces and silently drops events under CPU load → the inspector could sit on 'warming up' while iter_*.png frames pile up on disk unrendered (reported: 'ran but not plotting', frames present + valid on disk). Add a 700ms polling backstop to RunsRootWatcher: re-resolve latest, rescan the active run for the newest frame + FINISHED, and poke the log tailer. fs.watch stays for low latency; the poll guarantees delivery. All sinks are idempotent (frame dedup by iter, finishedSeen guard, log byte-offset) so the two paths never double-deliver. LogTailer.poke() drains only after attach() sets the start offset (never re-reads replayed lines). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/src/file_watcher.ts | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index b307c5dea..c3755aa3e 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -100,6 +100,14 @@ export class RunsRootWatcher implements vscode.Disposable { * promote prompt fires at most once per run, never re-popping on re-switch / * launch-follows-latest. */ private readonly promotedRuns = new Set(); + /** Polling backstop. macOS fs.watch (FSEvents) coalesces and silently drops + * events — especially under load — so the symlink-follow + per-frame watches + * miss `latest` swings and `iter_*.png` creations, leaving the inspector + * stuck (no live frames). A cheap periodic rescan guarantees delivery; the + * fs.watch paths stay for low latency. All sinks are idempotent (frame dedup + * by iter, finishedSeen, log byte-offset), so double-delivery is harmless. */ + private poll?: NodeJS.Timeout; + private static readonly POLL_MS = 700; constructor(private readonly opts: RunsRootWatcherOptions) {} @@ -110,10 +118,33 @@ export class RunsRootWatcher implements vscode.Disposable { this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { if (filename === "latest") this.followLatest(); }); - this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}`); + this.poll = setInterval(() => this.tick(), RunsRootWatcher.POLL_MS); + this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot} (fs.watch + ${RunsRootWatcher.POLL_MS}ms poll)`); + } + + /** fs.watch backstop: re-resolve `latest`, then rescan the active run for new + * frames / FINISHED and drain the log — catching anything FSEvents dropped. */ + private tick(): void { + try { + if (fs.existsSync(path.join(this.opts.runsRoot, "latest"))) this.followLatest(); + const runDir = this.activeRunDir; + if (!runDir || !this.sink) return; + let newest = -1, newestPath: string | undefined; + for (const f of fs.readdirSync(runDir)) { + const m = ITER_PNG_RE.exec(f); + if (m) { const k = parseInt(m[1], 10); if (k > newest) { newest = k; newestPath = path.join(runDir, f); } } + } + if (newestPath) this.sink.image(newestPath, newest); // deduped by latestIter + if (!this.finishedSeen && fs.existsSync(path.join(runDir, "FINISHED"))) { + this.finishedSeen = true; this.onFinished(runDir); + } + this.logTailer?.poke(); // drain appended AMICODE_ITER lines + } catch { /* transient fs race — next tick retries */ } } dispose(): void { + if (this.poll) clearInterval(this.poll); + this.poll = undefined; try { this.rootWatcher?.close(); } catch { /* noop */ } try { this.activeRunWatcher?.close(); } catch { /* noop */ } this.logTailer?.dispose(); @@ -212,9 +243,16 @@ class LogTailer implements vscode.Disposable { private buf = ""; private pollTimer?: NodeJS.Timeout; private disposed = false; + private attached = false; constructor(private readonly opts: LogTailerOptions) {} + /** Backstop drain (called by the watcher's poll). No-op until attach() has set + * the start offset, so it never re-reads lines ingestRunDir already replayed. */ + poke(): void { + if (this.attached && !this.disposed) this.drain(); + } + start(): void { const tryAttach = () => { if (this.disposed) return; @@ -236,6 +274,7 @@ class LogTailer implements vscode.Disposable { // Start where ingestRunDir stopped reading (startOffset), not at current EOF — // otherwise lines appended between the replay read and this attach are lost. this.offset = this.opts.startOffset ?? 0; + this.attached = true; try { this.watcher = fs.watch(this.opts.path, { persistent: false }, (event) => { if (event === "change") this.drain(); From 20abb9847f336b5c0c5cdc389008a93a8bea9738 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 12:35:50 -0400 Subject: [PATCH 06/10] fix(inspector): clear previous run's plot on new-run warm-up; PLOT_EVERY 6 - A new run's 'warming up' state now clears the previous run's image + stats, so the old iter-N plot no longer lingers on screen while the new solve compiles (reported: 'displaying iter 60 from the old one as the new iters run'). - Template plots every 6 iters (was 10) for more frequent live frames. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/src/inspector_webview.ts | 9 +++++++-- packages/extension/templates/solve_template.jl | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 901c08178..ee1f6e7af 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -37,8 +37,13 @@ window.addEventListener("message", (e) => { break; } case "warming": { - // A run started but Julia/Makie are still compiling — show that instead of - // an idle panel, so the cold start doesn't read as frozen. + // A NEW run started but has no frame yet — clear the PREVIOUS run's plot + + // stats and show the warming message, so the old iter-N image doesn't linger + // on screen while the new solve compiles/warms up. + (document.getElementById("preview-a") as HTMLImageElement).style.opacity = "0"; + (document.getElementById("preview-b") as HTMLImageElement).style.opacity = "0"; + for (const id of ["m-obj", "m-iter", "m-pr", "m-du"]) $(id).textContent = "–"; + $("m-obj-k").textContent = "objective"; const ph = document.getElementById("placeholder"); const hint = document.getElementById("m-hint"); if (hint) hint.textContent = "Julia warming up — compiling the solver + plotter (~1–2 min). Frames will stream here."; diff --git a/packages/extension/templates/solve_template.jl b/packages/extension/templates/solve_template.jl index 9b752205f..aa01b62b1 100644 --- a/packages/extension/templates/solve_template.jl +++ b/packages/extension/templates/solve_template.jl @@ -38,7 +38,7 @@ prob = hasproperty(qcp, :prob) ? qcp.prob : qcp # (e.g. `LivePulsePlotCallback`), which fires `(primal, iter)` across backends. const CB = Piccolo.Callbacks -const PLOT_EVERY = 10 +const PLOT_EVERY = 6 # plot every 6 iters (more frequent live frames) iters = Ref(0) function cb_log(optimizer, st; kwargs...) k = Int(st.iter_count); iters[] = k From e21539c801f7013c09199992d07f80c4549c1dbb Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 17:16:35 -0400 Subject: [PATCH 07/10] fix(inspector): display live frames (frame/log dedup split) + fold Jack review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real "plots never display" bug: LiveRunSink shared one counter between image() and iter(). run.log AMICODE_ITER lines stream once per iteration and race the counter to max BEFORE the lagging PNG frames land (solver logs iter=k, then writes iter_k.png), so every image() hit `k <= latest` and was dropped — blank inspector for the whole solve. Split into SinkDedup: frames dedup only against frames; log lines advance a separate high-water mark for the status bar. Extracted SinkDedup to the pure, vscode-free run_dir_reader layer so the live path is finally unit-tested (Jack #9: "no test covering the live status-bar / incremental inspector path" — the exact gap this regression slipped through). Also folds in the remaining non-blocking review nits (kept here rather than restacking the 7-deep chain to place one-liners on their home branches): - #13: #runlabel was styled but never populated → setRunLabel + webview handler. - #12: resolveJuliaProject now expands a leading ~ (parity with resolveRunsRoot). - #9: server_manager ServerOptions.env comment no longer cites the removed AMICODE_EXTENSION_URL/MCP-callback env. - #9/#11: AGENTS.md step-2 author path now matches step-3 run path (/tmp/amicode-work/solve.jl) and the invocation passes --lab default (run provenance — amico-run supports --lab). Plus the cold-start display polish already in flight on this branch: launch stays idle for a prior finished run (no stale plot), and the placeholder toggles via style.display (the [hidden] attr was overridden by .placeholder{display:flex}). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 20 ++++++---- packages/extension/src/file_watcher.ts | 28 ++++++++++---- packages/extension/src/inspector_webview.ts | 8 +++- packages/extension/src/opencode_config.ts | 9 ++++- packages/extension/src/run_dir_reader.ts | 25 +++++++++++++ packages/extension/src/run_inspector.ts | 14 +++++++ packages/extension/src/server_manager.ts | 4 +- .../extension/test/opencode_config.test.ts | 4 ++ .../extension/test/watcher_contract.test.ts | 37 ++++++++++++++++++- 9 files changed, 127 insertions(+), 22 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index b3c57e226..5fbe3796b 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -8,18 +8,22 @@ and the Run Inspector renders the live solve. 1. Read the bundled template `solve_template.jl` at its absolute path: `{{TEMPLATE_PATH}}`. -2. Copy it to a working file (e.g. `solve.jl`) and fill in the `# FILL IN` - parameter block from the user's request: transmon frequency `ω` (GHz), - anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T` (ns), - timesteps `N`, `max_iter`. **Parameters live in the script — never in this - file.** If the user gives a `lab.toml` path, read it in the script. +2. Copy it to `/tmp/amicode-work/solve.jl` (the exact path step 3 runs) and fill + in the `# FILL IN` parameter block from the user's request: transmon frequency + `ω` (GHz), anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T` + (ns), timesteps `N`, `max_iter`. **Parameters live in the script — never in + this file.** If the user gives a `lab.toml` path, read it in the script. + ```bash + mkdir -p /tmp/amicode-work && cp {{TEMPLATE_PATH}} /tmp/amicode-work/solve.jl + # …edit /tmp/amicode-work/solve.jl's FILL IN block… + ``` 3. Run it **detached** so the chat doesn't block on the ~minutes-long solve: ```bash - mkdir -p /tmp/amicode-work - ( nohup amico-run --project /tmp/amicode-work/solve.jl \ + ( nohup amico-run --project --lab default /tmp/amicode-work/solve.jl \ > /tmp/amicode-work/solve.log 2>&1 < /dev/null & ) ``` - (use the project path provided below). The outer subshell returns in <1s. + (use the project path provided below; `--lab default` tags the run's lab so + it's recorded under `~/.amico/runs/default/`). The outer subshell returns in <1s. `amico-run` takes only a script path and runner flags — it parses **no** physics options; all the physics lives in the script you wrote. Then immediately tell the user: **"Solve launched — watch the Run Inspector diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index c3755aa3e..a9054b496 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -6,7 +6,7 @@ import { getInspector } from "./run_inspector"; import type { StatusBarManager } from "./status_bar"; import type { RunStatus } from "./types"; import { - AMICODE_ITER_RE, ITER_PNG_RE, ingestRunDir, readTomlSafe, parseAmicoNum, + AMICODE_ITER_RE, ITER_PNG_RE, ingestRunDir, readTomlSafe, parseAmicoNum, SinkDedup, type IterRecord, type RunCompletion, type PromoteInfo, type RunSink, } from "./run_dir_reader"; @@ -34,7 +34,9 @@ export interface RunsRootWatcherOptions { /** Live sink: routes to the Inspector + status bar, carrying newest-wins and * promote-once guards so replay-then-incremental never double-fires. */ class LiveRunSink implements RunSink { - private latestIter = -1; + /** Newest-wins guard: frame display vs log-line iters tracked separately so the + * log high-water mark can't suppress lagging frames (see SinkDedup). */ + private readonly dedup = new SinkDedup(); constructor( private readonly opts: RunsRootWatcherOptions, private readonly runId: string, @@ -44,12 +46,11 @@ class LiveRunSink implements RunSink { ) {} image(fsPath: string, iter: number): void { - if (iter <= this.latestIter) return; - this.latestIter = iter; + if (!this.dedup.acceptFrame(iter)) return; // dedup on FRAMES only — see SinkDedup getInspector()?.setImageSource(fsPath, iter); } iter(rec: IterRecord): void { - if (rec.iter > this.latestIter) this.latestIter = rec.iter; + this.dedup.noteIter(rec.iter); getInspector()?.postIterationRecord(rec); // Live status-bar update — show "running · iter N" as it solves, not only at // completion (#5 AC3). @@ -65,7 +66,7 @@ class LiveRunSink implements RunSink { getInspector()?.postCompletion(c.status, c.fidelity); this.opts.statusBar?.setRun({ runId: c.runId, outputDir: c.runDir, startedAt: 0, - status: c.status, latestIter: this.latestIter >= 0 ? this.latestIter : undefined, + status: c.status, latestIter: this.dedup.high >= 0 ? this.dedup.high : undefined, fidelity: c.fidelity, }); this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`); @@ -114,7 +115,17 @@ export class RunsRootWatcher implements vscode.Disposable { start(): void { fs.mkdirSync(this.opts.runsRoot, { recursive: true }); const latest = path.join(this.opts.runsRoot, "latest"); - if (fs.existsSync(latest)) this.followLatest(); + if (fs.existsSync(latest)) { + // On launch, stay IDLE for a previous, already-finished run — don't re-display + // its last plot. Only resume a still-running run. A run that starts AFTER + // launch is picked up normally (idle → warming → frames). To baseline a + // finished run we set activeRunDir WITHOUT a sink, so the poll won't render it. + try { + const target = fs.realpathSync(latest); + if (fs.existsSync(path.join(target, "FINISHED"))) { this.activeRunDir = target; this.finishedSeen = true; } + else this.followLatest(); + } catch { /* noop */ } + } this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { if (filename === "latest") this.followLatest(); }); @@ -134,7 +145,7 @@ export class RunsRootWatcher implements vscode.Disposable { const m = ITER_PNG_RE.exec(f); if (m) { const k = parseInt(m[1], 10); if (k > newest) { newest = k; newestPath = path.join(runDir, f); } } } - if (newestPath) this.sink.image(newestPath, newest); // deduped by latestIter + if (newestPath) this.sink.image(newestPath, newest); // deduped by lastFrameIter if (!this.finishedSeen && fs.existsSync(path.join(runDir, "FINISHED"))) { this.finishedSeen = true; this.onFinished(runDir); } @@ -177,6 +188,7 @@ export class RunsRootWatcher implements vscode.Disposable { this.sink = new LiveRunSink(this.opts, runId, runDir, this.promotedRuns); getInspector()?.reveal(); + getInspector()?.setRunLabel(runId); // Replay everything already on disk (late-join safe). Returns the run.log // bytes consumed so the tailer attaches exactly there (no skipped iters). diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index ee1f6e7af..33857c803 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -27,6 +27,10 @@ window.addEventListener("message", (e) => { vscodeApi.postMessage({ type: "pong", seq: msg.seq, t0: msg.t0 }); break; } + case "runlabel": { + $("runlabel").textContent = String(msg.text ?? ""); + break; + } case "iteration": { $("m-obj-k").textContent = "objective"; $("m-iter").textContent = String(msg.iter); @@ -47,7 +51,7 @@ window.addEventListener("message", (e) => { const ph = document.getElementById("placeholder"); const hint = document.getElementById("m-hint"); if (hint) hint.textContent = "Julia warming up — compiling the solver + plotter (~1–2 min). Frames will stream here."; - if (ph) ph.hidden = false; + if (ph) ph.style.display = "flex"; // explicit: [hidden] is overridden by .placeholder{display:flex} setBadge("running", "warming up"); break; } @@ -64,7 +68,7 @@ window.addEventListener("message", (e) => { } case "refresh": { const placeholder = document.getElementById("placeholder"); - if (placeholder) placeholder.hidden = true; + if (placeholder) placeholder.style.display = "none"; // explicit hide (see warming note) // Double-buffer image swap — preload into hidden buffer, flip opacity on decode. const incomingBuffer = visibleBuffer === "a" ? "b" : "a"; diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index fed609e8a..f1acb7ab7 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -25,10 +25,15 @@ import * as os from "node:os"; /** Resolve the Julia project (--project) the agent should pass. A configured, * non-empty value wins (trimmed); otherwise default to the β.4-provisioned * project at ~/.amico/julia. (The VS Code config default is "", which `??` - * does NOT catch — hence an explicit empty check rather than a nullish one.) */ + * does NOT catch — hence an explicit empty check rather than a nullish one.) + * A leading `~` is expanded, mirroring resolveRunsRoot — so `~/foo` doesn't + * reach `--project` literally. */ export function resolveJuliaProject(configValue: string): string { const v = configValue.trim(); - return v === "" ? path.join(os.homedir(), ".amico", "julia") : v; + if (v === "") return path.join(os.homedir(), ".amico", "julia"); + if (v === "~") return os.homedir(); + if (v.startsWith("~/")) return path.join(os.homedir(), v.slice(2)); + return v; } /** Build the OPENCODE_CONFIG_CONTENT value: a config object that injects the diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index e810faf1d..0b59a8f9b 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -39,6 +39,31 @@ export interface RunSink { promote(info: PromoteInfo): void; } +/** Newest-wins guard for the live sink. Frame display and log-line iters are + * tracked SEPARATELY on purpose: run.log `AMICODE_ITER` lines arrive once per + * iteration and race ahead of the PNG frames (the solver logs `iter=k`, *then* + * writes `iter_k.png`). If frame dedup shared the log high-water mark, every + * frame would test `k <= high` and be dropped — leaving the inspector blank + * for the whole solve. So frames dedup only against prior FRAMES. + * Pure + vscode-free so it's unit-testable (LiveRunSink delegates to it). */ +export class SinkDedup { + private lastFrameIter = -1; + private latestIter = -1; + /** True if this frame is newer than the last DISPLAYED frame (→ forward it). */ + acceptFrame(iter: number): boolean { + if (iter <= this.lastFrameIter) return false; + this.lastFrameIter = iter; + if (iter > this.latestIter) this.latestIter = iter; + return true; + } + /** Record a log-line iter — advances the high-water mark only, never frames. */ + noteIter(iter: number): void { + if (iter > this.latestIter) this.latestIter = iter; + } + /** Highest iter seen from any source (drives the status bar / completion). */ + get high(): number { return this.latestIter; } +} + export function readTomlSafe(fp: string): Record | undefined { try { return parse(fs.readFileSync(fp, "utf8")) as Record; } catch { return undefined; } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 38f027cb0..28eed8e34 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -29,6 +29,9 @@ class InspectorView implements vscode.WebviewViewProvider { /** A run started but hasn't emitted its first frame yet (Julia warming up). * Buffered so the warming state shows even if the panel opens late. */ private bufferedWarming = false; + /** Run label (runId) for the topbar — buffered so it shows even if the panel + * opens after the run was selected. */ + private bufferedRunLabel?: string; constructor(private readonly ctx: vscode.ExtensionContext, private readonly runsRoot: string) {} @@ -48,6 +51,11 @@ class InspectorView implements vscode.WebviewViewProvider { view.webview.html = this.renderHtml(view.webview); view.onDidDispose(() => { this.view = undefined; this.clearTimer(); }); + // Topbar run label — replay first so it's set regardless of run state. + if (this.bufferedRunLabel) { + view.webview.postMessage({ type: "runlabel", text: this.bufferedRunLabel }); + this.bufferedRunLabel = undefined; + } // A run is warming up (no frame yet) — show that until the first frame. if (this.bufferedWarming && !this.bufferedImage) { this.bufferedWarming = false; @@ -134,6 +142,12 @@ class InspectorView implements vscode.WebviewViewProvider { this.view.webview.postMessage({ type: "warming" }); } + /** Set the topbar run label (runId). Buffered until the webview materializes. */ + setRunLabel(label: string): void { + if (!this.view) { this.bufferedRunLabel = label; return; } + this.view.webview.postMessage({ type: "runlabel", text: label }); + } + reveal(): void { // Force materialize the view via its auto-registered .focus command. // Unconditional — without an existing view, this is what creates one. diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 47f4008d9..960d4d061 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -21,7 +21,9 @@ export interface ServerOptions { binary: string; /** cwd for opencode — opencode reads project config from here. */ cwd: string; - /** env vars to inject (e.g. AMICODE_EXTENSION_URL for plugin/MCP callback). */ + /** env vars to inject into the opencode process (e.g. OPENCODE_CONFIG_CONTENT + * for the instructions/permission merge, and PATH augmentation so amico-run + * resolves). */ env: Record; /** OutputChannel for opencode stdout/stderr capture. */ channel: vscode.OutputChannel; diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 3c2f24a68..da3620a73 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -22,6 +22,10 @@ describe('resolveJuliaProject', () => { expect(resolveJuliaProject('/opt/piccolo')).toBe('/opt/piccolo') expect(resolveJuliaProject(' /opt/p ')).toBe('/opt/p') }) + it('expands a leading ~ (parity with resolveRunsRoot)', () => { + expect(resolveJuliaProject('~')).toBe(homedir()) + expect(resolveJuliaProject('~/foo/bar')).toBe(join(homedir(), 'foo', 'bar')) + }) }) describe('buildOpencodeConfigContent', () => { diff --git a/packages/extension/test/watcher_contract.test.ts b/packages/extension/test/watcher_contract.test.ts index e9f0ac779..9428e4eb0 100644 --- a/packages/extension/test/watcher_contract.test.ts +++ b/packages/extension/test/watcher_contract.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { ingestRunDir, AMICODE_ITER_RE, parseAmicoNum } from '../src/run_dir_reader' // pure β.1-contract reader (vscode-free) +import { ingestRunDir, AMICODE_ITER_RE, parseAmicoNum, SinkDedup } from '../src/run_dir_reader' // pure β.1-contract reader (vscode-free) function stageRun(opts: { status: string; exit: number; iters: number[]; fidelity?: number }): string { const root = mkdtempSync(join(tmpdir(), 'runs-')) @@ -64,3 +64,38 @@ describe('AMICODE_ITER parsing — Inf/NaN are kept, not dropped', () => { expect(parseAmicoNum('1.5e-3')).toBeCloseTo(0.0015) }) }) + +// The live inspector path (LiveRunSink) delegates frame/iter dedup to SinkDedup. +// This is the exact gap Jack flagged on #9 ("no test covering the live status-bar +// / incremental inspector path") — and where a regression silently blanked every +// frame: run.log lines advanced a shared counter past the lagging PNG frames, so +// every image() call was deduped away. +describe('SinkDedup — live frame/iter dedup (the path that blanked the inspector)', () => { + it('accepts a strictly-increasing frame, rejects re-delivery (poll + fs.watch overlap)', () => { + const d = new SinkDedup() + expect(d.acceptFrame(6)).toBe(true) + expect(d.acceptFrame(6)).toBe(false) // same frame re-seen by the poll backstop + expect(d.acceptFrame(12)).toBe(true) + expect(d.acceptFrame(7)).toBe(false) // an older frame can't clobber a newer one + }) + + it('log-line iters do NOT suppress lagging frames (regression guard)', () => { + const d = new SinkDedup() + // run.log streams iter=1..60 (fast) before the iter_0006.png frame lands. + for (let k = 1; k <= 60; k++) d.noteIter(k) + // The frame for iter 6 must STILL display — it dedups on frames, not log lines. + expect(d.acceptFrame(6)).toBe(true) + expect(d.acceptFrame(12)).toBe(true) + expect(d.acceptFrame(60)).toBe(true) + }) + + it('high() tracks the max across both sources (status bar / completion iter N)', () => { + const d = new SinkDedup() + expect(d.high).toBe(-1) + d.acceptFrame(6) + d.noteIter(42) + expect(d.high).toBe(42) + d.acceptFrame(60) + expect(d.high).toBe(60) + }) +}) From 32438de5c0255456c3c938882af541c10fef656b Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Mon, 22 Jun 2026 17:20:38 -0400 Subject: [PATCH 08/10] =?UTF-8?q?fix(demo):=20track=20the=20bundled=20run.?= =?UTF-8?q?log=20(was=20*.log-gitignored=20=E2=86=92=20never=20shipped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jack #11 must-change ("bundle a real run.log") never actually landed: the demo run.log is matched by .gitignore's `*.log`, so it was never committed. .vscodeignore has `!demo/**` so it'd ship IF present on disk — meaning it only "worked" on the capture machine; a clean checkout / CI VSIX shipped the demo with NO run.log → blank stats row, exactly the failure he flagged. Un-ignore the asset (.gitignore negation), track it, and assert it in the packaging manifest test so a future drop is caught. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + packages/extension/demo/run/run.log | 129 ++++++++++++++++++++++ packages/extension/test/packaging.test.ts | 1 + 3 files changed, 131 insertions(+) create mode 100644 packages/extension/demo/run/run.log diff --git a/.gitignore b/.gitignore index 5b28ba899..4a95eb749 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ dist/ *.vsix .vscode-test/ *.log +!packages/extension/demo/run/run.log packages/extension/vendor/ packages/extension/bin/ diff --git a/packages/extension/demo/run/run.log b/packages/extension/demo/run/run.log new file mode 100644 index 000000000..77f45541a --- /dev/null +++ b/packages/extension/demo/run/run.log @@ -0,0 +1,129 @@ +constructing SmoothPulseProblem [UnitaryTrajectory] +┌ Warning: Trajectory has timestep variable :Δt but no bounds on it. +│ Adding default lower bound of 0 to prevent negative timesteps. +│ +│ Recommended: Add explicit bounds when creating the trajectory: +│ NamedTrajectory(...; Δt_bounds=(min, max)) +│ Example: +│ NamedTrajectory(qtraj, N; Δt_bounds=(1e-3, 0.5)) +│ +│ Or use timesteps_all_equal=true in problem options to fix timesteps. +└ @ DirectTrajOpt.Problems ~/.julia/packages/DirectTrajOpt/TIf6x/src/problems.jl:66 +QuantumControlProblem +├─ UnitaryTrajectory · ZeroOrderPulse · BilinearIntegrator, DerivativeIntegrator, DerivativeIntegrator +│ +├─ System +│ dim=3 drives=2 +│ +├─ Trajectory +│ N=50 T=10.000 Δt∈[0, Inf] +│ Ũ⃗ (18) ±[1.0, 1.0, 1.0, … (18 total)] ✓ state +│ Δt ( 1) [0.0, Inf] ✓ timestep +│ t ( 1) · state +│ u ( 2) ±[0.2, 0.2] ✓ control +│ du ( 2) · control +│ ddu ( 2) ±[1.0, 1.0] ✓ control +│ +├─ Goal +│ EmbeddedOperator on [3], subspace dim 2 +│ +├─ Objective total = 44.95 @ current x +│ KnotPointObjective w=1 44.25 +│ QuadraticRegularizer(:u) w=1 2.042e-04 +│ QuadraticRegularizer(:du) w=1 9.849e-03 +│ QuadraticRegularizer(:ddu) w=1 0.6964 +│ NullObjective w=1 0 +│ +├─ Constraints 1/14 violated at x₀ +│ [dyn] BilinearIntegrator ✗ (‖c‖∞ = 0.09876) +│ [dyn] DerivativeIntegrator ✓ (‖c‖∞ = 2.776e-17) +│ [dyn] DerivativeIntegrator ✓ (‖c‖∞ = 2.220e-16) +│ [ineq] AllEqualConstraint ✓ (no eval) +│ [eq] EqualityConstraint ✓ (no eval) +│ [eq] EqualityConstraint ✓ (no eval) +│ [eq] EqualityConstraint ✓ (no eval) +│ [bnd] BoundsConstraint ✓ +│ [bnd] BoundsConstraint ✓ +│ [bnd] BoundsConstraint ✓ +│ [bnd] BoundsConstraint ✓ +│ [bnd] BoundsConstraint ✓ +│ [eq] TimeConsistencyConstraint ✓ (no eval) +│ [eq] EqualityConstraint ✓ (no eval) +│ +└─ Status + variables: 1300 (1100 bounded) + equality: 52827 + inequality: 1 + F (raw) = 0.557531 + +Hint: show_problem(qcp; detail=:full) for pulse plot + sparsity + +AMICODE_ITER iter=0 f=4.427415e+01 inf_pr=2.715e+00 inf_du=4.401e+00 + +****************************************************************************** +This program contains Ipopt, a library for large-scale nonlinear optimization. + Ipopt is released as open source code under the Eclipse Public License (EPL). + For more information visit https://github.com/coin-or/Ipopt +****************************************************************************** + +AMICODE_ITER iter=1 f=3.312001e+00 inf_pr=1.962e+00 inf_du=1.835e+02 +AMICODE_ITER iter=2 f=1.909965e+01 inf_pr=1.305e-01 inf_du=1.641e+02 +AMICODE_ITER iter=3 f=2.688086e+01 inf_pr=2.734e-03 inf_du=1.741e+02 +AMICODE_ITER iter=4 f=2.499717e+01 inf_pr=2.649e-04 inf_du=1.925e+02 +AMICODE_ITER iter=5 f=2.047457e+01 inf_pr=1.168e-03 inf_du=1.436e+00 +AMICODE_ITER iter=6 f=1.371681e+01 inf_pr=1.334e-03 inf_du=1.135e+00 +AMICODE_ITER iter=7 f=2.567280e+00 inf_pr=5.849e-03 inf_du=4.532e+00 +AMICODE_ITER iter=8 f=1.243844e+00 inf_pr=1.448e-02 inf_du=5.058e+03 +AMICODE_ITER iter=9 f=2.715532e+00 inf_pr=7.488e-03 inf_du=1.266e+05 +AMICODE_ITER iter=10 f=2.760619e+00 inf_pr=2.607e-04 inf_du=2.939e+04 +AMICODE_ITER iter=11 f=2.141433e+00 inf_pr=5.075e-05 inf_du=2.069e+00 +AMICODE_ITER iter=12 f=1.090665e+00 inf_pr=3.007e-04 inf_du=1.943e-01 +AMICODE_ITER iter=13 f=2.750855e-02 inf_pr=7.233e-04 inf_du=1.921e+02 +AMICODE_ITER iter=14 f=7.579673e-01 inf_pr=6.602e-04 inf_du=1.915e+02 +AMICODE_ITER iter=15 f=8.604789e-01 inf_pr=9.254e-07 inf_du=9.379e-01 +AMICODE_ITER iter=16 f=7.798056e-01 inf_pr=4.902e-06 inf_du=1.591e-01 +AMICODE_ITER iter=17 f=5.809716e-01 inf_pr=3.379e-05 inf_du=1.330e-01 +AMICODE_ITER iter=18 f=2.319975e-01 inf_pr=1.465e-04 inf_du=9.073e-02 +AMICODE_ITER iter=19 f=5.657439e-02 inf_pr=2.357e-04 inf_du=1.924e+02 +AMICODE_ITER iter=20 f=1.418744e-01 inf_pr=1.465e-04 inf_du=1.923e+02 +AMICODE_ITER iter=21 f=1.615507e-01 inf_pr=2.529e-07 inf_du=5.215e-01 +AMICODE_ITER iter=22 f=1.421320e-01 inf_pr=1.716e-06 inf_du=6.246e-02 +AMICODE_ITER iter=23 f=9.743153e-02 inf_pr=1.068e-05 inf_du=5.106e-02 +AMICODE_ITER iter=24 f=3.100677e-02 inf_pr=3.676e-05 inf_du=3.206e-02 +AMICODE_ITER iter=25 f=2.149879e-02 inf_pr=4.050e-05 inf_du=1.924e+02 +AMICODE_ITER iter=26 f=2.110705e-02 inf_pr=1.111e-05 inf_du=1.924e+02 +AMICODE_ITER iter=27 f=2.287865e-02 inf_pr=4.976e-08 inf_du=3.663e-01 +AMICODE_ITER iter=28 f=2.057141e-02 inf_pr=2.819e-07 inf_du=1.757e-02 +AMICODE_ITER iter=29 f=1.566875e-02 inf_pr=1.540e-06 inf_du=1.382e-02 +AMICODE_ITER iter=30 f=9.845458e-03 inf_pr=3.980e-06 inf_du=7.545e-03 +AMICODE_ITER iter=31 f=1.037462e-02 inf_pr=3.020e-06 inf_du=1.924e+02 +AMICODE_ITER iter=32 f=9.708787e-03 inf_pr=1.299e-06 inf_du=1.924e+02 +AMICODE_ITER iter=33 f=9.757572e-03 inf_pr=4.887e-08 inf_du=3.527e-01 +AMICODE_ITER iter=34 f=9.648300e-03 inf_pr=2.990e-08 inf_du=3.208e-03 +AMICODE_ITER iter=35 f=9.435164e-03 inf_pr=8.478e-08 inf_du=2.684e-03 +AMICODE_ITER iter=36 f=9.281332e-03 inf_pr=1.850e-07 inf_du=1.924e+02 +AMICODE_ITER iter=37 f=9.315456e-03 inf_pr=3.351e-07 inf_du=1.924e+02 +AMICODE_ITER iter=38 f=9.332486e-03 inf_pr=1.542e-10 inf_du=1.467e-02 +AMICODE_ITER iter=39 f=9.317899e-03 inf_pr=1.125e-09 inf_du=2.186e-03 +AMICODE_ITER iter=40 f=9.283247e-03 inf_pr=7.451e-09 inf_du=2.047e-03 +AMICODE_ITER iter=41 f=9.226014e-03 inf_pr=3.074e-08 inf_du=1.767e-03 +AMICODE_ITER iter=42 f=9.195011e-03 inf_pr=6.195e-08 inf_du=1.924e+02 +AMICODE_ITER iter=43 f=9.136038e-03 inf_pr=3.153e-07 inf_du=1.924e+02 +AMICODE_ITER iter=44 f=9.137616e-03 inf_pr=4.528e-11 inf_du=7.841e-03 +AMICODE_ITER iter=45 f=9.134479e-03 inf_pr=3.146e-10 inf_du=1.453e-03 +AMICODE_ITER iter=46 f=9.126828e-03 inf_pr=2.038e-09 inf_du=1.389e-03 +AMICODE_ITER iter=47 f=9.111456e-03 inf_pr=8.248e-09 inf_du=1.377e-03 +AMICODE_ITER iter=48 f=9.088690e-03 inf_pr=5.431e-08 inf_du=1.924e+02 +AMICODE_ITER iter=49 f=9.001065e-03 inf_pr=4.205e-07 inf_du=1.924e+02 +AMICODE_ITER iter=50 f=9.000330e-03 inf_pr=7.382e-10 inf_du=3.516e-02 +AMICODE_ITER iter=51 f=8.998946e-03 inf_pr=2.533e-10 inf_du=1.200e-03 +AMICODE_ITER iter=52 f=8.994278e-03 inf_pr=1.160e-09 inf_du=1.212e-03 +AMICODE_ITER iter=53 f=8.982299e-03 inf_pr=9.944e-09 inf_du=1.924e+02 +AMICODE_ITER iter=54 f=8.943076e-03 inf_pr=8.767e-08 inf_du=1.924e+02 +AMICODE_ITER iter=55 f=8.942956e-03 inf_pr=6.080e-12 inf_du=3.079e-03 +AMICODE_ITER iter=56 f=8.942226e-03 inf_pr=2.578e-11 inf_du=1.137e-03 +AMICODE_ITER iter=57 f=8.940084e-03 inf_pr=2.319e-10 inf_du=1.141e-03 +AMICODE_ITER iter=58 f=8.933903e-03 inf_pr=2.064e-09 inf_du=1.924e+02 +AMICODE_ITER iter=59 f=8.917972e-03 inf_pr=1.428e-08 inf_du=1.924e+02 +AMICODE_ITER iter=60 f=8.917966e-03 inf_pr=7.926e-13 inf_du=1.106e-03 +DONE fidelity=0.9999788203047787 diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 246e470ef..6bb38fd76 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -13,6 +13,7 @@ const REQUIRED = [ 'extension/AGENTS.md', 'extension/demo/run/manifest.toml', 'extension/demo/run/FINISHED', + 'extension/demo/run/run.log', // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop ] // Guards against a silently-dropped runtime asset (the β.2 .gitignore-fallback From afc60209b22212d212c8df5b6277abc10269d66f Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Thu, 25 Jun 2026 14:25:47 -0400 Subject: [PATCH 09/10] remove the local sysimage build feature (not worth the ~50-min build) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-machine PackageCompiler build (CairoMakie native-compile dominated, ~25-50 min, per-machine, non-shippable) wasn't worth it even opt-in. Removed: - julia/build_sysimage.jl + julia/sysimage_exercise.jl (the build scripts) - the `sysimage` npm script - the install.sh build block (AMICO_BUILD_SYSIMAGE gating) - amico-run's auto-detect of /amico-sysimage.{dylib,so} + its test Kept the generic `--sysimage ` flag + manifest plumbing (already on main from β.1): it's the passive hook for the intended fast-path — a prebuilt sysimage distributed like Piccolissimo's (CI build on self-hosted binary-builder runners → tarball+sha256 → Cloudflare R2 → manifest.json → download), pointed at via the flag. That's a separate effort; a Piccolissimo image can't be reused directly since amicode's needs Piccolo + CairoMakie baked in. Until that exists, every solve pays the cold start and the inspector shows "warming up" — which is now the whole of this PR's perf story. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/amico-run/src/cli.ts | 16 ++++----- packages/amico-run/test/cli.test.ts | 12 ------- packages/extension/julia/build_sysimage.jl | 27 -------------- packages/extension/julia/sysimage_exercise.jl | 36 ------------------- packages/extension/package.json | 1 - packages/extension/scripts/install.sh | 22 ------------ 6 files changed, 6 insertions(+), 108 deletions(-) delete mode 100644 packages/extension/julia/build_sysimage.jl delete mode 100644 packages/extension/julia/sysimage_exercise.jl diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index 395d8a477..ccbe1f647 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -40,16 +40,12 @@ export async function main(argv: string[]): Promise { if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 } if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 } - // Auto-detect a prebuilt sysimage next to the project when one wasn't passed. - // A sysimage with Piccolo + CairoMakie baked in collapses the ~100s cold start - // (Julia/Piccolo load + Makie's first-plot compile) to a few seconds. install.sh - // builds it at `/amico-sysimage.{dylib,so}`; agents need no flag change. - if (!opts.julia!.sysimage && opts.julia!.project) { - for (const ext of ['dylib', 'so']) { - const cand = join(opts.julia!.project, `amico-sysimage.${ext}`) - if (existsSync(cand)) { opts.julia!.sysimage = cand; break } - } - } + // NOTE: `--sysimage ` is honored (passed through to the Julia process and + // recorded in the manifest) but amicode does NOT build one — the local + // PackageCompiler build (~25-50 min, CairoMakie-dominated) wasn't worth it. The + // intended fast-path is a prebuilt sysimage distributed like Piccolissimo's + // (CI build on self-hosted runners → R2 → manifest → download), pointed at via + // this flag. Until that exists, solves pay the cold start (inspector warms up). let handle try { diff --git a/packages/amico-run/test/cli.test.ts b/packages/amico-run/test/cli.test.ts index ecefbe8fc..892cc966e 100644 --- a/packages/amico-run/test/cli.test.ts +++ b/packages/amico-run/test/cli.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeAll } from 'vitest' import { execFileSync, execFile } from 'node:child_process' -import { mkdirSync, writeFileSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpRoot, fakeJulia } from './helpers.js' @@ -48,17 +47,6 @@ describe('amico-run CLI', () => { expect(r.code).toBe(64) expect(r.stderr).toMatch(/unknown flag/) }) - it('auto-detects a sysimage beside --project (no --sysimage flag) → manifest records it', () => { - const root = tmpRoot() - const project = join(root, 'proj'); mkdirSync(project, { recursive: true }) - const sysimg = join(project, 'amico-sysimage.dylib'); writeFileSync(sysimg, 'FAKE') - const julia = fakeJulia(root, 'j', `console.log('DONE f=0.99')`) - const script = fakeJulia(root, 's.jl', '') - const runsRoot = join(root, 'runs') - const r = run([script, '--runs-root', runsRoot, '--julia', julia, '--project', project]) - expect(r.code).toBe(0) - expect(readFileSync(join(runsRoot, 'latest', 'manifest.toml'), 'utf8')).toContain(sysimg) - }) it('--executor remote → 64 (only local in β)', () => { const root = tmpRoot() const r = run([fakeJulia(root, 's.jl', ''), '--executor', 'remote']) diff --git a/packages/extension/julia/build_sysimage.jl b/packages/extension/julia/build_sysimage.jl deleted file mode 100644 index 5a6e6fb99..000000000 --- a/packages/extension/julia/build_sysimage.jl +++ /dev/null @@ -1,27 +0,0 @@ -# Build the amico sysimage: bake Piccolo + CairoMakie (+ JLD2/TOML/Printf) and -# the solve/plot code paths into a native image so amico-run starts a solve in -# seconds instead of paying ~100s of Julia/Makie compilation on the first run. -# -# Run with PackageCompiler available (install.sh adds it to the global env): -# AMICO_JULIA_PROJECT=~/.amico/julia julia build_sysimage.jl -# -# Output: /amico-sysimage.{dylib|so} — amico-run auto-detects it. -using PackageCompiler - -const PROJECT = get(ENV, "AMICO_JULIA_PROJECT", joinpath(homedir(), ".amico", "julia")) -const HERE = @__DIR__ -const SYSIMG = joinpath(PROJECT, Sys.isapple() ? "amico-sysimage.dylib" : "amico-sysimage.so") -const EXERCISE = joinpath(HERE, "sysimage_exercise.jl") - -@info "building amico sysimage" project=PROJECT output=SYSIMG -# Only direct, non-stdlib project deps go in the package list; Printf et al. are -# pure stdlibs (always in the sysimage) and their methods get baked via the -# precompile-execution trace. cpu_target left at PackageCompiler's default -# (native to the build machine, which is where install.sh runs it). -create_sysimage( - [:Piccolo, :CairoMakie, :JLD2, :TOML]; - project = PROJECT, - sysimage_path = SYSIMG, - precompile_execution_file = EXERCISE, -) -@info "sysimage built" path=SYSIMG bytes=(isfile(SYSIMG) ? filesize(SYSIMG) : 0) diff --git a/packages/extension/julia/sysimage_exercise.jl b/packages/extension/julia/sysimage_exercise.jl deleted file mode 100644 index 1ac47ab48..000000000 --- a/packages/extension/julia/sysimage_exercise.jl +++ /dev/null @@ -1,36 +0,0 @@ -# Precompile-execution workload for the amico sysimage build. Exercises exactly -# the code paths a real solve hits — TransmonSystem / EmbeddedOperator / -# SmoothPulseProblem / solve! (Ipopt) / plot_pulse (Makie render+save) / rollout -# / unitary_fidelity — so PackageCompiler bakes their compiled methods into the -# sysimage. That removes the ~80s first-iter JIT and the ~35s first-plot compile. -# Kept tiny (few iters) — we want coverage, not convergence. -using Piccolo -using CairoMakie -using JLD2 -using TOML -using Printf - -try - sys = TransmonSystem(; δ = 0.2, levels = 3, drive_bounds = fill(0.2, 2)) - op = EmbeddedOperator(GATES[:X], sys) - times = collect(range(0.0, 10.0, length = 20)) - qtraj = UnitaryTrajectory(sys, ZeroOrderPulse(0.1 * randn(sys.n_drives, 20), times), op) - qcp = SmoothPulseProblem(qtraj, 20; - piccolo_options = PiccoloOptions(timesteps_all_equal = true), Q = 100.0, R = 1e-2) - solve!(qcp; max_iter = 3, print_level = 0) - - # plot path (the expensive first-render compile we most want baked) - fig = plot_pulse(qcp; bounds = true) - CairoMakie.save(tempname() * ".png", fig) - - # rollout + subspace fidelity (the result metric) + JLD2/TOML serialization - Uroll = iso_vec_to_operator(unitary_rollout(get_trajectory(qcp), sys)[:, end]) - unitary_fidelity(Uroll, op.operator; subspace = op.subspace) - mktempdir() do d - JLD2.save(joinpath(d, "p.jld2"), "traj", qcp isa Any ? get_trajectory(qcp) : nothing) - open(joinpath(d, "r.toml"), "w") do io; TOML.print(io, Dict("fidelity" => 0.99)); end - end - @info "sysimage exercise complete" -catch e - @warn "sysimage exercise hit an error (sysimage still builds; coverage may be partial)" exception = e -end diff --git a/packages/extension/package.json b/packages/extension/package.json index 58f4ff427..405684032 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -111,7 +111,6 @@ "test:slow": "vitest run test/slow", "test:smoke": "node test/boot_smoke.mjs", "fetch:opencode": "node scripts/fetch_opencode.mjs", - "sysimage": "julia -e 'using Pkg; Pkg.add(\"PackageCompiler\")' && julia julia/build_sysimage.jl", "healthcheck": "node scripts/healthcheck.mjs", "package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix" }, diff --git a/packages/extension/scripts/install.sh b/packages/extension/scripts/install.sh index 57b5fe899..de1b7000d 100755 --- a/packages/extension/scripts/install.sh +++ b/packages/extension/scripts/install.sh @@ -21,28 +21,6 @@ cp "$EXT_ROOT/julia/Manifest.toml" "$JULIA_PROJECT/Manifest.toml" say "instantiating Piccolo project at $JULIA_PROJECT (first run precompiles - be patient)..." julia --project="$JULIA_PROJECT" -e 'using Pkg; Pkg.instantiate()' || die "Pkg.instantiate failed (see Julia error above)" -# 2b. OPTIONAL sysimage (bakes Piccolo + CairoMakie + solve/plot paths → first -# solve starts in seconds instead of ~100s of JIT). OPT-IN, not on the install -# critical path: the native compile (CairoMakie-dominated) is a long one-time -# build (~25-50 min, machine-dependent), so we don't block install on it. Build -# it when you want faster solves: `AMICO_BUILD_SYSIMAGE=1 bash install.sh`, or -# `pnpm --filter amicode-v2 sysimage`. amico-run auto-detects it once present; -# until then solves just pay the cold start (the inspector shows "warming up"). -SYSIMG_DYLIB="$JULIA_PROJECT/amico-sysimage.dylib"; SYSIMG_SO="$JULIA_PROJECT/amico-sysimage.so" -if [ -f "$SYSIMG_DYLIB" ] || [ -f "$SYSIMG_SO" ]; then - say "sysimage present - amico-run will auto-detect it (fast solves)" -elif [ "${AMICO_BUILD_SYSIMAGE:-0}" = "1" ]; then - say "building sysimage (one-time, ~25-50 min; CairoMakie native-compile is the long pole)..." - if julia -e 'using Pkg; Pkg.add("PackageCompiler")' \ - && AMICO_JULIA_PROJECT="$JULIA_PROJECT" julia "$EXT_ROOT/julia/build_sysimage.jl"; then - say "sysimage built - amico-run will auto-detect it" - else - say "WARNING: sysimage build failed - the lab still works, first solve just pays the cold start" - fi -else - say "tip: for fast solves (no ~2-min cold start), build the sysimage once: AMICO_BUILD_SYSIMAGE=1 bash $EXT_ROOT/scripts/install.sh" -fi - # 3. Install the VSIX if command -v code >/dev/null 2>&1; then [ -f "$VSIX" ] || die "VSIX not found at $VSIX - build it: pnpm --filter amicode-v2 package" From e83d76abbe3ad0bfbbb2b421b335b2a3fb3ac2aa Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Fri, 26 Jun 2026 20:05:00 -0400 Subject: [PATCH 10/10] test(watcher): cover the live state machine; address Jack's #23 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [important] Add a test for the live RunsRootWatcher state machine — the poll backstop + idle-on-finished-at-launch baseline + warming→first-frame transition, which the SinkDedup unit test (the dedup primitive) did not cover. The test drives the watcher over a temp run dir and asserts the inspector calls: - a run already FINISHED at launch stays idle (no stale plot re-rendered), - a fresh run shows warming-up, then the poll delivers the newest frame (newest-wins across a tick), then completion fires once. Infra: a minimal `vscode` stub aliased via vitest.config.ts so node-side modules that import vscode are unit-testable; the inspector is mocked to capture calls; tick() is driven directly so the poll path is deterministic (no 700ms race). [minor] file_watcher.ts: comment that the poll delivers only the newest frame per tick by design (latest pulse, not an animation) — not a dropped-frame bug. [minor] solve_template.jl: note the --- packages/extension/src/file_watcher.ts | 4 + .../extension/templates/solve_template.jl | 6 +- packages/extension/test/__mocks__/vscode.ts | 30 ++++++ .../test/watcher_statemachine.test.ts | 92 +++++++++++++++++++ packages/extension/vitest.config.ts | 11 +++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 packages/extension/test/__mocks__/vscode.ts create mode 100644 packages/extension/test/watcher_statemachine.test.ts create mode 100644 packages/extension/vitest.config.ts diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index a9054b496..ae44008c5 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -140,6 +140,10 @@ export class RunsRootWatcher implements vscode.Disposable { if (fs.existsSync(path.join(this.opts.runsRoot, "latest"))) this.followLatest(); const runDir = this.activeRunDir; if (!runDir || !this.sink) return; + // Deliver only the NEWEST frame this tick — frames produced between two + // ticks are intentionally skipped. The inspector shows the latest pulse, + // not an animation, so a coalesced frame is no loss (and the fs.watch path + // still catches most frames at low latency). Not a dropped-frame bug. let newest = -1, newestPath: string | undefined; for (const f of fs.readdirSync(runDir)) { const m = ITER_PNG_RE.exec(f); diff --git a/packages/extension/templates/solve_template.jl b/packages/extension/templates/solve_template.jl index aa01b62b1..9225410a7 100644 --- a/packages/extension/templates/solve_template.jl +++ b/packages/extension/templates/solve_template.jl @@ -38,7 +38,11 @@ prob = hasproperty(qcp, :prob) ? qcp.prob : qcp # (e.g. `LivePulsePlotCallback`), which fires `(primal, iter)` across backends. const CB = Piccolo.Callbacks -const PLOT_EVERY = 6 # plot every 6 iters (more frequent live frames) +# Plot every 6 iters (frequent live frames), skipping iter-0. Edge case: a solve +# that converges in <6 iters emits no per-iter frame — the inspector shows +# "warming up" until the end-of-solve guarantee frame below. Acceptable: the +# warming-up state covers it, and sub-6-iter solves are rare in this regime. +const PLOT_EVERY = 6 iters = Ref(0) function cb_log(optimizer, st; kwargs...) k = Int(st.iter_count); iters[] = k diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts new file mode 100644 index 000000000..1995b5c29 --- /dev/null +++ b/packages/extension/test/__mocks__/vscode.ts @@ -0,0 +1,30 @@ +// Minimal `vscode` stub for unit tests (aliased in vitest.config.ts). Provides +// only the runtime members our node-side modules touch; types are erased at +// compile time so they need no runtime shape. +export const window = { + showInformationMessage: () => Promise.resolve(undefined), + showErrorMessage: () => Promise.resolve(undefined), + showWarningMessage: () => Promise.resolve(undefined), + createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), +}; +export const commands = { executeCommand: () => Promise.resolve(undefined) }; +export const workspace = { + workspaceFolders: [] as unknown[], + getConfiguration: () => ({ get: (_k: string, d?: unknown) => d ?? "" }), +}; +export const Uri = { + file: (p: string) => ({ fsPath: p, toString: () => p }), + joinPath: (base: { fsPath?: string } | string, ...parts: string[]) => { + const root = typeof base === "string" ? base : base.fsPath ?? ""; + const full = [root, ...parts].join("/"); + return { fsPath: full, toString: () => full }; + }, +}; +export class EventEmitter { + event = () => ({ dispose() {} }); + fire() {} + dispose() {} +} +export class Disposable { + dispose() {} +} diff --git a/packages/extension/test/watcher_statemachine.test.ts b/packages/extension/test/watcher_statemachine.test.ts new file mode 100644 index 000000000..add34ea77 --- /dev/null +++ b/packages/extension/test/watcher_statemachine.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Drive the live RunsRootWatcher state machine over a temp run dir and assert the +// inspector calls — the poll backstop + idle-on-finished baseline + warming→frame +// transition that the SinkDedup unit test does NOT cover (Jack's #23 [important]). +// +// The inspector is mocked (so getInspector() returns spies); `vscode` is the +// aliased stub (vitest.config.ts). We call the private tick() directly so the +// poll path is exercised deterministically instead of racing the 700ms timer. + +const { inspector } = vi.hoisted(() => ({ + inspector: { + setImageSource: vi.fn(), + setWarmingUp: vi.fn(), + postCompletion: vi.fn(), + postIterationRecord: vi.fn(), + setRunLabel: vi.fn(), + reveal: vi.fn(), + }, +})); +vi.mock("../src/run_inspector", () => ({ getInspector: () => inspector })); + +import { RunsRootWatcher } from "../src/file_watcher"; + +const channel = { appendLine() {}, append() {} } as never; + +function writeManifest(dir: string, runId: string): void { + writeFileSync(join(dir, "manifest.toml"), + `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + + `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`); +} +function setLatest(root: string, target: string): void { + const link = join(root, "latest"); + try { rmSync(link); } catch { /* none */ } + symlinkSync(target, link); +} +const tick = (w: RunsRootWatcher): void => (w as unknown as { tick(): void }).tick(); + +describe("RunsRootWatcher state machine", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("a run already FINISHED at launch stays idle — no stale plot re-rendered", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const run = join(root, "r1"); mkdirSync(run); + writeManifest(run, "r1"); + writeFileSync(join(run, "iter_6.png"), "PNG"); // a frame is on disk… + writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + setLatest(root, run); + + const w = new RunsRootWatcher({ runsRoot: root, channel }); + w.start(); + tick(w); // even after a poll, a finished-at-launch run must render nothing + expect(inspector.setImageSource).not.toHaveBeenCalled(); // …but it's NOT shown + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); + w.dispose(); + }); + + it("fresh run → warming-up → poll delivers newest frame (newest-wins) → completion", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const run = join(root, "r2"); mkdirSync(run); + writeManifest(run, "r2"); // manifest only, no frames yet + setLatest(root, run); + + const w = new RunsRootWatcher({ runsRoot: root, channel }); + w.start(); + // fresh run with no frames → warming, not idle, not a frame + expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); + expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); + expect(inspector.setImageSource).not.toHaveBeenCalled(); + + // first frame appears; the poll backstop delivers it (no reliance on fs.watch) + writeFileSync(join(run, "iter_6.png"), "PNG"); + tick(w); + expect(inspector.setImageSource).toHaveBeenLastCalledWith(expect.stringContaining("iter_6.png"), 6); + + // two frames land between ticks → only the NEWEST is delivered + writeFileSync(join(run, "iter_12.png"), "PNG"); + writeFileSync(join(run, "iter_18.png"), "PNG"); + tick(w); + expect(inspector.setImageSource).toHaveBeenLastCalledWith(expect.stringContaining("iter_18.png"), 18); + + // FINISHED + result → terminal completion delivered once + writeFileSync(join(run, "result.toml"), "fidelity = 0.9999\niterations = 18\n"); + writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + tick(w); + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + w.dispose(); + }); +}); diff --git a/packages/extension/vitest.config.ts b/packages/extension/vitest.config.ts new file mode 100644 index 000000000..96b1b7f45 --- /dev/null +++ b/packages/extension/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +// Alias the `vscode` module to a minimal stub so node-side modules that import it +// (file_watcher.ts, etc.) can be unit-tested without the VS Code host. Only kicks +// in for `import ... from "vscode"`; node-only tests are unaffected. +export default defineConfig({ + resolve: { + alias: { vscode: path.resolve(process.cwd(), "test/__mocks__/vscode.ts") }, + }, +});