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/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index b00ddfbc3..ccbe1f647 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -40,6 +40,13 @@ 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 } + // 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 { handle = await new LocalExecutor().submit(script, opts) 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/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/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index beea80d2a..ae44008c5 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)}` : ""}`); @@ -100,20 +101,65 @@ 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) {} 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(); }); - 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; + // 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); + 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 lastFrameIter + 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(); @@ -146,6 +192,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). @@ -154,6 +201,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; @@ -207,9 +259,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; @@ -231,6 +290,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(); diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index d34edf3eb..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); @@ -36,6 +40,21 @@ window.addEventListener("message", (e) => { setBadge("running", "running"); break; } + case "warming": { + // 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."; + if (ph) ph.style.display = "flex"; // explicit: [hidden] is overridden by .placeholder{display:flex} + setBadge("running", "warming up"); + break; + } case "completed": { // Authoritative terminal state from the watcher (FINISHED on disk). const ok = msg.status === "completed"; @@ -49,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 94e41b1c1..28eed8e34 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -26,6 +26,12 @@ 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; + /** 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) {} @@ -45,6 +51,16 @@ 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; + 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 +130,24 @@ 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" }); + } + + /** 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. @@ -226,7 +260,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/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/templates/solve_template.jl b/packages/extension/templates/solve_template.jl index 2f8ee4417..9225410a7 100644 --- a/packages/extension/templates/solve_template.jl +++ b/packages/extension/templates/solve_template.jl @@ -38,13 +38,17 @@ prob = hasproperty(qcp, :prob) ? qcp.prob : qcp # (e.g. `LivePulsePlotCallback`), which fires `(primal, iter)` across backends. const CB = Piccolo.Callbacks -const PLOT_EVERY = 10 +# 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 @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 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/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/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 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) + }) +}) 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") }, + }, +});