diff --git a/packages/extension/src/demo_replay.ts b/packages/extension/src/demo_replay.ts index 8451f04b..c5005fc6 100644 --- a/packages/extension/src/demo_replay.ts +++ b/packages/extension/src/demo_replay.ts @@ -7,7 +7,7 @@ import { generateRunId, appendIndex, updateLatest } from "@amicode/amico-run"; * rewrite run.toml's `run_id` to match the new directory, append the * index, and swing `latest` to it. Reuses the β.1 run-dir primitives so the * staged run is byte-for-byte contract-identical and the existing - * RunsRootWatcher renders it exactly like a live solve. + * RunsManager discovers it off the index and renders it like a live solve. * * Filesystem side effects only (pure w.r.t. its inputs). Returns the staged * run directory. diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index b3c3bac5..ab4cc0e7 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -13,7 +13,7 @@ import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; -import { RunsRootWatcher } from "./file_watcher"; +import { RunsManager } from "./runs_manager"; import { stageDemoRun } from "./demo_replay"; import { readTomlSafe } from "./run_dir_reader"; @@ -29,7 +29,7 @@ import { readTomlSafe } from "./run_dir_reader"; let serverManager: ServerManager | undefined; let statusBar: StatusBarManager | undefined; let sseClient: OpencodeEventClient | undefined; -let watcher: RunsRootWatcher | undefined; +let runsManager: RunsManager | undefined; let opencodeReadyUrl: URL | undefined; @@ -90,12 +90,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { statusBar = new StatusBarManager(); ctx.subscriptions.push({ dispose: () => statusBar?.dispose() }); - // 2. Start watching the runs root immediately — solves may already exist - // from prior dev-host sessions, and watchers are cheap. + // 2. Start the multi-run RunsManager immediately — it tails the append-only + // runs/index (1.2, #57), so solves from prior dev-host sessions register and + // a still-live run resumes; every concurrent run is tracked to completion. fs.mkdirSync(runsRoot, { recursive: true }); - watcher = new RunsRootWatcher({ runsRoot, channel: runsChannel, statusBar }); - watcher.start(); - ctx.subscriptions.push(watcher); + runsManager = new RunsManager({ runsRoot, channel: runsChannel, statusBar }); + runsManager.start(); + ctx.subscriptions.push(runsManager); // Validate lab.toml on load (0.1b / S17). A malformed hardware profile would // otherwise silently solve against the wrong hardware or fail opaquely mid-solve. @@ -241,8 +242,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } }), // On-site fallback (β.6): stage the bundled pre-baked solve into the runs - // root. The watcher already running on runsRoot follows `latest` → - // ingestRunDir replays the converged solve — no Julia, no opencode, no creds. + // root. Under the multi-run RunsManager (#57) a run that is FINISHED at + // discovery registers quietly (no auto-display), so the demo is shown by + // EXPLICIT selection: poke the index tail (same-tick registration), then + // selectRun → the display replay renders the converged solve — no Julia, + // no opencode, no creds. Promote stays suppressed (finished at discovery). vscode.commands.registerCommand("amicode.replayDemo", async () => { const demoDir = path.join(ctx.extensionPath, "demo", "run"); if (!fs.existsSync(path.join(demoDir, "FINISHED"))) { @@ -251,6 +255,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } try { const runDir = stageDemoRun(demoDir, runsRoot); + runsManager?.pokeDiscovery(); + runsManager?.selectRun(path.basename(runDir)); runsChannel.appendLine(`[demo] replayed → ${runDir}`); await vscode.commands.executeCommand("amicode.runInspector.focus"); // Save-to-catalog prompt (#47): the watcher suppresses the promote @@ -276,7 +282,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { export function deactivate(): void { sseClient?.dispose(); serverManager?.stop(); - watcher?.dispose(); + runsManager?.dispose(); statusBar?.dispose(); } diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts deleted file mode 100644 index bf25b419..00000000 --- a/packages/extension/src/file_watcher.ts +++ /dev/null @@ -1,339 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as vscode from "vscode"; -import { validateFinished, validateResult } from "@amicode/amico-run"; -import { getInspector } from "./run_inspector"; -import type { StatusBarManager } from "./status_bar"; -import type { RunStatus } from "./types"; -import { - AMICODE_ITER_RE, ingestRunDir, readTomlSafe, parseAmicoNum, PulseStream, SinkDedup, - type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink, -} from "./run_dir_reader"; - -// ============================================================================ -// RunsRootWatcher — watches the β.1 run-dir contract and drives the Inspector -// + status bar. Follows the `latest` symlink; for the active run it reads: -// run.toml → run identity (run_id, lab_id), written FIRST -// run.log → AMICODE_ITER lines → live stats row · AMICODE_PULSE -// lines → native live pulse plot (#66; iter_.png stays a -// run-dir/archival artifact but is no longer displayed) -// result.toml → fidelity (display + promote gate), atomic -// FINISHED → authoritative terminal signal {status, exit_code} -// -// Completion keys on FINISHED (not result.toml). The contract-reading logic is -// the pure `ingestRunDir` in run_dir_reader.ts (replay/late-join, unit-tested); -// the live path here adds incremental fs.watch + run.log tailing. -// ============================================================================ - -export interface RunsRootWatcherOptions { - runsRoot: string; - channel: vscode.OutputChannel; - statusBar?: StatusBarManager; - promoteThreshold?: number; -} - -/** 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 { - /** 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(); - /** Live pulse-line gate (#66). Re-armed by replayed meta events so tailed - * records after a mid-flight switch keep their governing shape. */ - private readonly pulses = new PulseStream(); - constructor( - private readonly opts: RunsRootWatcherOptions, - private readonly runId: string, - private readonly runDir: string, - /** Shared across run-switches so a run promotes at most once (no re-pop). */ - private readonly promotedRuns: Set, - ) {} - - iter(rec: IterRecord): void { - 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). - this.opts.statusBar?.setRun({ - runId: this.runId, outputDir: this.runDir, startedAt: 0, - status: "running", latestIter: rec.iter, - }); - } - run(c: RunCompletion): void { - // Tell the inspector the run is terminal so the badge leaves "running". - // Fires on live finish (onFinished) AND replay of an already-finished run - // (ingestRunDir) — both route through this sink. - getInspector()?.postCompletion(c.status, c.fidelity); - this.opts.statusBar?.setRun({ - runId: c.runId, outputDir: c.runDir, startedAt: 0, - 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)}` : ""}`); - if (c.status !== "completed") { - this.opts.channel.appendLine(`[runs] see ${path.join(c.runDir, "run.log")}`); - } - } - pulse(e: PulseEvent): void { - if (e.type === "meta") this.pulses.arm(e.meta); - getInspector()?.postPulse(e); - } - /** Tail path (#66 AC6): feed a raw run.log line through the pulse gate and - * forward any accepted event. On a live run this is the ONLY meta carrier — - * ingest ran against an empty log. */ - pulseLine(line: string): void { - const e = this.pulses.onLine(line); - if (e) this.pulse(e); - } - promote(info: PromoteInfo): void { - if (this.promotedRuns.has(info.runId)) return; - this.promotedRuns.add(info.runId); - void (async () => { - const choice = await vscode.window.showInformationMessage( - `Amicode: solve converged (F=${info.fidelity.toFixed(4)}). Promote pulse to catalog?`, - "Yes — promote", "No — keep local only", - ); - if (choice === "Yes — promote") { - // #47: record in the session catalog + open the card (store - // persistence is still Phase 3 — the session catalog is workspaceState). - await vscode.commands.executeCommand("amicode.catalog.save", info.runDir).then(undefined, () => undefined); - } - })(); - } -} - -export class RunsRootWatcher implements vscode.Disposable { - private rootWatcher?: fs.FSWatcher; - private activeRunDir?: string; - private activeRunWatcher?: fs.FSWatcher; - private logTailer?: LogTailer; - private sink?: LiveRunSink; - private finishedSeen = false; - /** Runs already promoted (or already-finished when first switched to) — so the - * 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 + run-dir watches - * can miss `latest` swings and FINISHED, and the log tailer's change events - * can go quiet. The periodic tick re-resolves `latest`, re-checks FINISHED, - * and pokes the tailer (which self-attaches if run.log appeared unseen); the - * fs.watch paths stay for low latency. All sinks are idempotent - * (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)) { - // 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.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; - 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(); - this.rootWatcher = undefined; - this.activeRunWatcher = undefined; - this.logTailer = undefined; - } - - private followLatest(): void { - let target: string | undefined; - try { target = fs.realpathSync(path.join(this.opts.runsRoot, "latest")); } - catch (err) { this.opts.channel.appendLine(`[runs] latest unresolved: ${(err as Error).message}`); return; } - if (target === this.activeRunDir) return; - this.opts.channel.appendLine(`[runs] active run -> ${target}`); - this.switchToRun(target); - } - - private switchToRun(runDir: string): void { - try { this.activeRunWatcher?.close(); } catch { /* noop */ } - this.logTailer?.dispose(); - this.activeRunDir = runDir; - - const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir)); - // If the run was ALREADY finished when we switched to it (e.g. launch follows - // `latest` to a prior completed run, or the user switches back), don't pop the - // promote prompt — only a FRESH live completion promotes. Pre-marking the run - // suppresses the replay-driven promote below. - const finishedAtSwitch = fs.existsSync(path.join(runDir, "FINISHED")); - if (finishedAtSwitch) this.promotedRuns.add(runId); - - 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). - let logBytes = 0; - try { logBytes = ingestRunDir(runDir, this.sink, this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } - this.finishedSeen = finishedAtSwitch; - - // Fresh unfinished run → Julia warming up; show that instead of an idle - // panel so the ~minute cold start isn't read as frozen. The view swaps the - // hint for the plot when the first pulse record arrives. - if (!finishedAtSwitch) getInspector()?.setWarmingUp(); - - // Incremental: FINISHED (pulse/iter lines arrive via the log tailer). - this.activeRunWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => { - if (!filename) return; - if (!fs.existsSync(path.join(runDir, filename))) return; - if (filename === "FINISHED" && !this.finishedSeen) { this.finishedSeen = true; this.onFinished(runDir); } - }); - - // Incremental: appended AMICODE_ITER lines — start at the ingest offset so a - // line written between the replay read and this attach isn't skipped. - this.logTailer = new LogTailer({ - path: path.join(runDir, "run.log"), - startOffset: logBytes, - channel: this.opts.channel, - onLine: (line) => { - const m = AMICODE_ITER_RE.exec(line); - if (m) { this.sink?.iter({ iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); return; } - this.sink?.pulseLine(line); - }, - }); - this.logTailer.start(); - } - - private onFinished(runDir: string): void { - const finished = readTomlSafe(path.join(runDir, "FINISHED")); - if (!finished || !validateFinished(finished).ok) return; - const status = finished.status as RunStatus; - const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir)); - let fidelity: number | undefined; - if (status === "completed") { - const result = readTomlSafe(path.join(runDir, "result.toml")); - if (result) { - const v = validateResult(result); - if (v.ok) fidelity = result.fidelity as number; - // Don't silently drop fidelity + skip promote on a present-but-invalid - // result.toml — say why (S4). e.g. a pre-0.1a result.toml with no - // schema_version, or a fidelity gross-out-of-range. - else this.opts.channel.appendLine(`[runs] result.toml present but invalid: ${v.errors.join("; ")}`); - } - } - this.sink?.run({ runId, runDir, status, fidelity }); - if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { - this.sink?.promote({ runId, runDir, fidelity }); - } - } -} - -// =========================================================================== -// LogTailer — follows run.log as julia appends, emitting each new line. -// =========================================================================== - -interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } - -class LogTailer implements vscode.Disposable { - private watcher?: fs.FSWatcher; - private offset = 0; - 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). Attaches first if run.log - * has appeared since start() (the 250ms retry timer may not have fired yet — - * same coalesced-FSEvents rationale as the poll itself). attach() sets the - * start offset, so it never re-reads lines ingestRunDir already replayed. */ - poke(): void { - if (this.disposed) return; - if (!this.attached && fs.existsSync(this.opts.path)) this.attach(); - if (this.attached) this.drain(); - } - - start(): void { - const tryAttach = () => { - if (this.disposed) return; - if (fs.existsSync(this.opts.path)) this.attach(); - else this.pollTimer = setTimeout(tryAttach, 250); - }; - tryAttach(); - } - - dispose(): void { - this.disposed = true; - if (this.pollTimer) clearTimeout(this.pollTimer); - try { this.watcher?.close(); } catch { /* noop */ } - this.watcher = undefined; - } - - private attach(): void { - if (this.disposed || this.attached) return; - // 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(); - }); - } catch (err) { - this.opts.channel.appendLine(`[runs] log tail attach failed: ${(err as Error).message}`); - } - // Drain immediately to catch lines already written past startOffset. - this.drain(); - } - - private drain(): void { - if (this.disposed) return; - let fd: number; - try { fd = fs.openSync(this.opts.path, "r"); } catch { return; } - try { - const size = fs.fstatSync(fd).size; - if (size < this.offset) { this.offset = 0; this.buf = ""; } - if (size === this.offset) return; - const chunk = Buffer.allocUnsafe(size - this.offset); - const read = fs.readSync(fd, chunk, 0, chunk.length, this.offset); - this.offset += read; - this.buf += chunk.subarray(0, read).toString("utf8"); - let nl: number; - while ((nl = this.buf.indexOf("\n")) >= 0) { - const line = this.buf.slice(0, nl); - this.buf = this.buf.slice(nl + 1); - try { this.opts.onLine(line); } catch (e) { this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); } - } - } finally { - try { fs.closeSync(fd); } catch { /* noop */ } - } - } -} diff --git a/packages/extension/src/log_tailer.ts b/packages/extension/src/log_tailer.ts new file mode 100644 index 00000000..319f81c4 --- /dev/null +++ b/packages/extension/src/log_tailer.ts @@ -0,0 +1,91 @@ +import * as fs from "node:fs"; +import * as vscode from "vscode"; + +// =========================================================================== +// LogTailer — follows an append-only text file (run.log, runs/index) as lines +// are appended, emitting each new line exactly once. Extracted verbatim from +// file_watcher.ts for 1.2 (#57): the multi-run RunsManager runs one tailer per +// live run's run.log PLUS one on the append-only runs/index (discovery). +// =========================================================================== + +export interface LogTailerOptions { path: string; channel: vscode.OutputChannel; onLine: (line: string) => void; startOffset?: number } + +export class LogTailer implements vscode.Disposable { + private watcher?: fs.FSWatcher; + private offset = 0; + private buf = ""; + private pollTimer?: NodeJS.Timeout; + private disposed = false; + private attached = false; + + constructor(private readonly opts: LogTailerOptions) {} + + /** Backstop drain (called by the owner's poll). Attaches first if the file + * has appeared since start() (the 250ms retry timer may not have fired yet — + * same coalesced-FSEvents rationale as the poll itself). attach() sets the + * start offset, so it never re-reads lines a replay already consumed. */ + poke(): void { + if (this.disposed) return; + if (!this.attached && fs.existsSync(this.opts.path)) this.attach(); + if (this.attached) this.drain(); + } + + start(): void { + const tryAttach = () => { + if (this.disposed) return; + if (fs.existsSync(this.opts.path)) this.attach(); + else this.pollTimer = setTimeout(tryAttach, 250); + }; + tryAttach(); + } + + dispose(): void { + this.disposed = true; + if (this.pollTimer) clearTimeout(this.pollTimer); + try { this.watcher?.close(); } catch { /* noop */ } + this.watcher = undefined; + } + + private attach(): void { + if (this.disposed || this.attached) return; + // Start where the replay 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(); + }); + // Unhandled FSWatcher 'error' would crash the host; the owner's poll + // (poke) keeps draining even if this watcher dies. + this.watcher.on("error", (e) => this.opts.channel.appendLine(`[runs] log tail watch error: ${String(e)}`)); + } catch (err) { + this.opts.channel.appendLine(`[runs] log tail attach failed: ${(err as Error).message}`); + } + // Drain immediately to catch lines already written past startOffset. + this.drain(); + } + + private drain(): void { + if (this.disposed) return; + let fd: number; + try { fd = fs.openSync(this.opts.path, "r"); } catch { return; } + try { + const size = fs.fstatSync(fd).size; + if (size < this.offset) { this.offset = 0; this.buf = ""; } + if (size === this.offset) return; + const chunk = Buffer.allocUnsafe(size - this.offset); + const read = fs.readSync(fd, chunk, 0, chunk.length, this.offset); + this.offset += read; + this.buf += chunk.subarray(0, read).toString("utf8"); + let nl: number; + while ((nl = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + try { this.opts.onLine(line); } catch (e) { this.opts.channel.appendLine(`[runs] onLine threw: ${String(e)}`); } + } + } finally { + try { fs.closeSync(fd); } catch { /* noop */ } + } + } +} diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index c07f5acc..bfd81ae7 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -6,7 +6,7 @@ import type { RunStatus } from "./types"; // ============================================================================ // Pure (vscode-free) reader for the β.1 run-dir contract. Unit-testable in -// isolation; the vscode-coupled RunsRootWatcher (file_watcher.ts) consumes it. +// isolation; the vscode-coupled RunsManager (runs_manager.ts) consumes it. // ============================================================================ // Float group accepts Julia's @printf %e output incl. Inf/-Inf/NaN, so stagnation @@ -136,6 +136,34 @@ export function readTomlSafe(fp: string): Record | undefined { catch { return undefined; } } +/** Terminal state of a run dir, read + validated in ONE place (review #70: the + * FINISHED→status→result.toml→fidelity orchestration used to live both here + * and in RunsManager.readTerminal — a contract change had to be edited in two + * places or finished-at-discovery diverged from live-completed). + * + * Returns undefined while FINISHED is absent OR present-but-torn/invalid + * (mid-write) — callers retry on their next pass. A present-but-invalid + * result.toml is NAMED via `onInvalidResult` (S4: say why, never silently + * drop fidelity); the default keeps this reader vscode-free via console.warn. */ +export function readTerminalState( + runDir: string, + onInvalidResult: (why: string) => void = (why) => console.warn(`[amico] ${why}`), +): { status: RunStatus; fidelity?: number } | undefined { + const finished = readTomlSafe(path.join(runDir, "FINISHED")); + if (!finished || !validateFinished(finished).ok) return undefined; + const status = finished.status as RunStatus; + let fidelity: number | undefined; + if (status === "completed") { + const result = readTomlSafe(path.join(runDir, "result.toml")); + if (result) { + const v = validateResult(result); + if (v.ok) fidelity = result.fidelity as number; + else onInvalidResult(`result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`); + } + } + return { status, fidelity }; +} + /** Pure, stateless replay of a run dir against the β.1 contract. Calls each * sink method at most once per relevant artifact. Safe to re-invoke (the live * sink's guards make it idempotent). Returns the number of run.log bytes @@ -169,26 +197,13 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 if (newestPulse) sink.pulse(newestPulse); } - // FINISHED is the authoritative terminal signal - const finished = readTomlSafe(path.join(runDir, "FINISHED")); - if (!finished || !validateFinished(finished).ok) return logBytes; - const status = finished.status as RunStatus; - - let fidelity: number | undefined; - if (status === "completed") { - const result = readTomlSafe(path.join(runDir, "result.toml")); - if (result) { - const v = validateResult(result); - if (v.ok) fidelity = result.fidelity as number; - // Present-but-nonconforming result.toml: surface WHY rather than silently - // dropping fidelity + skipping promote (S4). console.warn keeps this reader - // vscode-free; the live watcher logs to its channel too. - else console.warn(`[amico] result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`); - } - } - sink.run({ runId, runDir, status, fidelity }); - if (status === "completed" && fidelity !== undefined && fidelity >= promoteThreshold) { - sink.promote({ runId, runDir, fidelity }); + // FINISHED is the authoritative terminal signal — single orchestration point + // (readTerminalState) shared with the manager's finished-at-discovery path. + const t = readTerminalState(runDir); + if (!t) return logBytes; + sink.run({ runId, runDir, status: t.status, fidelity: t.fidelity }); + if (t.status === "completed" && t.fidelity !== undefined && t.fidelity >= promoteThreshold) { + sink.promote({ runId, runDir, fidelity: t.fidelity }); } return logBytes; } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 60009024..846f0527 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -83,7 +83,7 @@ class InspectorView implements vscode.WebviewViewProvider { } } - // -------- public surface used by RunsRootWatcher -------- + // -------- public surface used by RunsManager -------- postIterationRecord(rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { // Ok to drop iter records pre-materialization: the stats row refreshes on diff --git a/packages/extension/src/run_registry.ts b/packages/extension/src/run_registry.ts new file mode 100644 index 00000000..95ec482d --- /dev/null +++ b/packages/extension/src/run_registry.ts @@ -0,0 +1,99 @@ +import type { RunStatus } from "./types"; + +// ============================================================================ +// Pure multi-run registry (1.2, #57) — vscode-free so the state machine is +// unit-testable. The append-only `runs/index` (written by amico-run's +// appendIndex: `runId\tcreatedAt\tscriptPath\n`) is the multi-run source of +// truth; RunsManager tails it and registers every run here. The `latest` +// symlink keeps being WRITTEN by amico-run (frozen contract) but is no longer +// followed for discovery. +// ============================================================================ + +export interface IndexEntry { + runId: string; + createdAt: string; + scriptPath: string; +} + +/** Parse one `runs/index` line (TSV: runId, createdAt, scriptPath). The writer + * sanitizes tabs/newlines out of the path, but tolerate extra tabs anyway by + * re-joining the tail. Malformed/blank lines → undefined (never throw — the + * index is append-only and a torn final line heals on the next tail drain). */ +export function parseIndexLine(line: string): IndexEntry | undefined { + if (!line || !line.trim()) return undefined; + const parts = line.split("\t"); + if (parts.length < 3) return undefined; + const [runId, createdAt, ...rest] = parts; + if (!runId || !createdAt) return undefined; + return { runId, createdAt, scriptPath: rest.join("\t") }; +} + +/** Where a run is in its lifecycle. `live` = discovered without FINISHED (a + * pipeline is tailing it); `finished` = FINISHED observed (authoritative, + * keyed on the FINISHED file — never on result.toml presence). */ +export type RunPhase = "live" | "finished"; + +export interface RunRecord { + runId: string; + runDir: string; + createdAt?: string; + scriptPath?: string; + phase: RunPhase; + /** Terminal status once phase === "finished". */ + status?: RunStatus; + fidelity?: number; + /** High-water AMICODE_ITER seen (drives the status bar). */ + latestIter?: number; +} + +/** Multi-run record store. Registration is idempotent by runId (the index + * replays from offset 0 on every launch; re-registration is a no-op). */ +export class RunRegistry { + private readonly map = new Map(); + + /** True if newly registered; false if the runId was already known. */ + register(rec: RunRecord): boolean { + if (this.map.has(rec.runId)) return false; + this.map.set(rec.runId, { ...rec }); + return true; + } + + /** Fill ONLY missing metadata on an existing record — a scheduler-registered + * run (runId+runDir only) gains createdAt/scriptPath when its index line + * lands later. Never overwrites present values (first registration wins for + * everything stateful). */ + backfill(runId: string, meta: { createdAt?: string; scriptPath?: string }): void { + const r = this.map.get(runId); + if (!r) return; + if (r.createdAt === undefined && meta.createdAt !== undefined) r.createdAt = meta.createdAt; + if (r.scriptPath === undefined && meta.scriptPath !== undefined) r.scriptPath = meta.scriptPath; + } + + get(runId: string): RunRecord | undefined { + return this.map.get(runId); + } + + /** Snapshot COPIES — callers (1.3 trees) can't mutate registry state. */ + all(): RunRecord[] { + return [...this.map.values()].map((r) => ({ ...r })); + } + + noteIter(runId: string, iter: number): void { + const r = this.map.get(runId); + if (!r) return; + if (r.latestIter === undefined || iter > r.latestIter) r.latestIter = iter; + } + + /** First terminal wins: re-marking an already-finished run is a no-op, so a + * stray second call can't leave e.g. status:"failed" beside a stale + * fidelity from an earlier "completed" (review #70 — the guard lives HERE, + * not only in the manager's completeRun, because this is public surface the + * 1.3 consumers touch). */ + markFinished(runId: string, status: RunStatus, fidelity?: number): void { + const r = this.map.get(runId); + if (!r || r.phase === "finished") return; + r.phase = "finished"; + r.status = status; + if (fidelity !== undefined) r.fidelity = fidelity; + } +} diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts new file mode 100644 index 00000000..75eb6df6 --- /dev/null +++ b/packages/extension/src/runs_manager.ts @@ -0,0 +1,414 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { getInspector } from "./run_inspector"; +import { LogTailer } from "./log_tailer"; +import { parseIndexLine, RunRegistry, type RunRecord } from "./run_registry"; +import type { StatusBarManager } from "./status_bar"; +import type { RunStatus } from "./types"; +import { + AMICODE_ITER_RE, ingestRunDir, readTerminalState, parseAmicoNum, PulseStream, SinkDedup, + type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink, +} from "./run_dir_reader"; + +// ============================================================================ +// RunsManager (1.2, #57) — the multi-run evolution of β's RunsRootWatcher. +// +// Discovery: tails the APPEND-ONLY `runs/index` (amico-run appends one TSV line +// per run) instead of following the `latest` symlink — `latest` keeps being +// written (frozen contract) but is display-era plumbing; the index is the +// multi-run source of truth. Every line registers a run; every run WITHOUT a +// FINISHED gets its own live pipeline (replay → run-dir watch → run.log tail), +// so N concurrent solves are ALL tracked to completion — a second solve no +// longer yanks tracking off the first mid-flight. +// +// Fan-out: per-run events land in the registry (state) and are ROUTED to the +// single-run Inspector/StatusBar only for the SELECTED run (1.3 fans the +// inspector itself into per-run views; `selectRun` is its seam). Completions +// and the promote prompt fire for EVERY run, selected or not. Selection +// auto-follows the newest started run (parity with β's latest-follow UX) — +// UNLESS a run was selected explicitly (selectRun pins; auto-follow defers), +// so a background solve can't yank the view off a deliberately-opened run. +// +// Completion keys on FINISHED (never result.toml presence); the contract +// reading is the pure `ingestRunDir`. Double-delivery between a selection +// replay and a live tail is tolerated by design — terminal state and the +// registry are idempotent, and the pulse/stats surfaces converge: the tailer +// may transiently re-deliver records OLDER than a replayed newest (plot/stats +// briefly regress) but every drain reads to EOF, so the last delivery is +// always the true newest (same rationale as the poll backstop). +// +// Scheduler (1.1, #56/#68): `attachScheduler` consumes the lifecycle stream — +// a `started` event registers the run immediately (faster than the index +// tail; also the only path for runs under a non-default runsRoot), with the +// same pin-aware auto-follow as index discovery. +// Structural type so this compiles independently of the Scheduler landing. +// ============================================================================ + +export interface RunsManagerOptions { + runsRoot: string; + channel: vscode.OutputChannel; + statusBar?: StatusBarManager; + promoteThreshold?: number; +} + +/** The #56 Scheduler's lifecycle surface (structural — see amico-run scheduler.ts). */ +export interface SchedulerLifecycleEvent { + kind: "queued" | "started" | "finished" | "cancelled" | "error"; + queueId: string; + runId?: string; + runDir?: string; + position?: number; + status?: string; + exitCode?: number; + message?: string; +} +export interface SchedulerLike { + onEvent(listener: (e: SchedulerLifecycleEvent) => void): () => void; +} + +/** One live run's incremental machinery. State-only: routing decisions live in + * the manager (selection may change while this pipeline runs). */ +class RunPipeline implements vscode.Disposable { + readonly pulses = new PulseStream(); + readonly dedup = new SinkDedup(); + finishedSeen = false; + dirWatcher?: fs.FSWatcher; + tailer?: LogTailer; + + constructor(readonly runId: string, readonly runDir: string) {} + + dispose(): void { + try { this.dirWatcher?.close(); } catch { /* noop */ } + this.tailer?.dispose(); + this.dirWatcher = undefined; + this.tailer = undefined; + } +} + +export class RunsManager implements vscode.Disposable { + private readonly registry = new RunRegistry(); + private readonly pipelines = new Map(); + private indexTailer?: LogTailer; + private rootWatcher?: fs.FSWatcher; + private poll?: NodeJS.Timeout; + private selected?: string; + /** True once a run was selected EXPLICITLY (selectRun — demo command, 1.3 + * user clicks). Auto-follow (a newly-registered live run taking the view, + * β latest-follow parity) only applies while NOT pinned — a background + * solve starting must never yank the view off a run the user deliberately + * opened (review #70; the seam 1.3's selection UI builds on). */ + private pinned = false; + private schedulerDispose?: () => void; + /** Promote-once + never-on-replay: runs finished at DISCOVERY are pre-marked + * so only a fresh live completion prompts (ports β's finishedAtSwitch). */ + private readonly promotedRuns = new Set(); + private static readonly POLL_MS = 700; + + constructor(private readonly opts: RunsManagerOptions) {} + + start(): void { + fs.mkdirSync(this.opts.runsRoot, { recursive: true }); + // Discovery = tail the append-only index from offset 0. Launch replays the + // whole history: finished runs register terminal (idle — nothing rendered); + // a run still live across a window reload gets a pipeline and, being the + // newest live line, wins auto-selection (resume, β parity). + this.indexTailer = new LogTailer({ + path: path.join(this.opts.runsRoot, "index"), + startOffset: 0, + channel: this.opts.channel, + onLine: (line) => { + const e = parseIndexLine(line); + if (e) this.registerRun(e.runId, path.join(this.opts.runsRoot, e.runId), e.createdAt, e.scriptPath); + }, + }); + this.indexTailer.start(); + this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => { + if (filename === "index") this.indexTailer?.poke(); + }); + // An unhandled FSWatcher 'error' is an uncaught exception in the extension + // host (e.g. the watched dir deleted). The poll backstop keeps us live. + this.rootWatcher.on("error", (e) => this.opts.channel.appendLine(`[runs] root watch error: ${String(e)}`)); + this.poll = setInterval(() => this.tick(), RunsManager.POLL_MS); + this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}/index (fs.watch + ${RunsManager.POLL_MS}ms poll)`); + } + + /** Poll backstop — macOS FSEvents coalesces/drops events, so re-poke the + * index tail and every live pipeline (FINISHED re-check + log drain). All + * consumers are idempotent, so double-delivery is harmless. */ + private tick(): void { + try { + this.indexTailer?.poke(); + for (const p of this.pipelines.values()) { + this.checkFinished(p); + p.tailer?.poke(); + } + } catch { /* transient fs race — next tick retries */ } + } + + dispose(): void { + if (this.poll) clearInterval(this.poll); + this.poll = undefined; + try { this.rootWatcher?.close(); } catch { /* noop */ } + this.rootWatcher = undefined; + this.indexTailer?.dispose(); + this.indexTailer = undefined; + this.schedulerDispose?.(); + this.schedulerDispose = undefined; + for (const p of this.pipelines.values()) p.dispose(); + this.pipelines.clear(); + } + + /** Consume the #56 Scheduler lifecycle: `started` registers the run + * immediately (its runDir is authoritative — may live outside runsRoot); + * auto-follow applies unless an explicit selection is pinned. */ + attachScheduler(scheduler: SchedulerLike): void { + this.schedulerDispose?.(); + this.schedulerDispose = scheduler.onEvent((e) => { + if (e.kind === "started" && e.runId && e.runDir) { + this.registerRun(e.runId, e.runDir); + return; + } + this.opts.channel.appendLine(`[runs] scheduler ${e.kind} ${e.runId ?? e.queueId}${e.message ? `: ${e.message}` : ""}`); + }); + } + + /** EXPLICIT selection (demo replay command; 1.3's user clicks): routes the + * single-run Inspector/StatusBar at a run AND PINS the selection — after + * this, auto-follow never steals the view (see `pinned`). Replays the run + * dir for display, then live events flow. */ + selectRun(runId: string): void { + const rec = this.registry.get(runId); + if (!rec) return; + this.pinned = true; + if (this.selected === runId) return; + this.selected = runId; + getInspector()?.reveal(); + getInspector()?.setRunLabel(runId); + // Display replay (late-join safe): full history from disk → inspector. + // Promote inside the replay stays guarded by promotedRuns, so re-selecting + // a finished run never re-pops the prompt. + try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } + catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + // Fresh/live run → Julia warming up (the view swaps the hint when the first + // pulse record arrives). Same post-replay order as β's switchToRun — and, + // like β, re-check DISK (not the registry phase): FINISHED may have landed + // inside the ≤700ms poll window, and warming-after-completion would invert + // the terminal badge until the next tick. + if (rec.phase !== "finished" && !fs.existsSync(path.join(rec.runDir, "FINISHED"))) { + getInspector()?.setWarmingUp(); + } + } + + /** Force immediate index-tail drain — for flows that just appended an index + * line (demo replay) and want same-tick registration instead of waiting on + * fs.watch/poll. */ + pokeDiscovery(): void { + this.indexTailer?.poke(); + } + + /** Registry snapshot (1.3 trees / tests). */ + runs(): RunRecord[] { + return this.registry.all(); + } + + get selectedRun(): string | undefined { + return this.selected; + } + + // -------- internal -------- + + private registerRun(runId: string, runDir: string, createdAt?: string, scriptPath?: string): void { + if (this.registry.get(runId)) { + // Idempotent — the index replays from 0 every launch. But a run first + // registered off the Scheduler's `started` event (runId+runDir only) + // gains its createdAt/scriptPath when the index line lands here. + this.registry.backfill(runId, { createdAt, scriptPath }); + return; + } + if (!fs.existsSync(runDir)) { + this.opts.channel.appendLine(`[runs] index names ${runId} but ${runDir} is missing — skipped`); + return; + } + const finishedAtDiscovery = fs.existsSync(path.join(runDir, "FINISHED")); + if (finishedAtDiscovery) { + const t = this.readTerminal(runDir); + if (t) { + // Terminal at discovery: record it (status/fidelity for the registry) but + // render nothing and never re-pop the promote prompt (β launch parity). + this.registry.register({ runId, runDir, createdAt, scriptPath, phase: "finished", status: t.status, fidelity: t.fidelity }); + this.promotedRuns.add(runId); + return; + } + // FINISHED present but torn/invalid (caught mid-write) — do NOT finalize + // with an undefined status that nothing revisits (review #70): fall + // through to the live path, whose checkFinished re-reads next tick (the + // same retry the live lane already has). Promote stays suppressed: + // terminal-at-discovery is a launch replay regardless of the torn write. + this.promotedRuns.add(runId); + } + this.registry.register({ runId, runDir, createdAt, scriptPath, phase: "live" }); + const p = new RunPipeline(runId, runDir); + this.pipelines.set(runId, p); + + // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED + // live run is by definition the newest start) — unless an explicit selection + // is pinned. Deciding first lets the ONE ingest below both seed pipeline + // state and feed the display through routeIter/routePulse's selection gate + // (review #70: the old shape parsed the whole run.log twice per discovery — + // a state pass, then selectRun's display pass). + const follow = !this.pinned; + if (follow && this.selected !== runId) { + this.selected = runId; + getInspector()?.reveal(); + getInspector()?.setRunLabel(runId); + } + + // Single replay: arms the pipeline's pulse stream (meta), seeds iter + // high-water, routes to the inspector iff selected above, and yields the + // byte offset the live tail starts from. + let logBytes = 0; + try { logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); } + catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + + // FINISHED landed between the existsSync check and the replay (rare race): + // completeRun already tore the pipeline down (and — selection was assigned + // above — showed the completion); don't attach watch/tail to a disposed + // pipeline. + if (this.registry.get(runId)?.phase === "finished") return; + + // Incremental: FINISHED (authoritative terminal), then appended log lines. + p.dirWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => { + if (filename === "FINISHED") this.checkFinished(p); + }); + p.dirWatcher.on("error", (e) => this.opts.channel.appendLine(`[runs] ${runId} dir watch error: ${String(e)}`)); + p.tailer = new LogTailer({ + path: path.join(runDir, "run.log"), + startOffset: logBytes, + channel: this.opts.channel, + onLine: (line) => { + const m = AMICODE_ITER_RE.exec(line); + if (m) { this.routeIter(p, { iter: +m[1], f_val: parseAmicoNum(m[2]), inf_pr: parseAmicoNum(m[3]), inf_du: parseAmicoNum(m[4]) }); return; } + const e = p.pulses.onLine(line); + if (e) this.routePulse(p.runId, e); + }, + }); + p.tailer.start(); + + // Fresh/live run with no data yet → Julia warming up (post-replay, β order). + // Disk-checked: a torn FINISHED (fall-through above) must not read "warming". + if (follow && !fs.existsSync(path.join(runDir, "FINISHED"))) { + getInspector()?.setWarmingUp(); + } + } + + /** Sink for a pipeline's SINGLE registration replay: seeds registry/pulse + * state and — because auto-follow assigns selection BEFORE the replay — + * feeds the display through routeIter/routePulse's selection gate in the + * same pass (review #70: no second display ingest). */ + private pipelineSink(p: RunPipeline): RunSink { + return { + iter: (rec: IterRecord) => this.routeIter(p, rec), + // A FINISHED that landed between the existsSync check and this replay — + // rare race; treat exactly like a live completion. + run: (c: RunCompletion) => this.completeRun(p.runId, c.status, c.fidelity), + pulse: (e: PulseEvent) => { + if (e.type === "meta") p.pulses.arm(e.meta); + this.routePulse(p.runId, e); + }, + promote: (info: PromoteInfo) => this.promptPromote(info), + }; + } + + /** Display sink for selection replays: inspector + status bar; promote stays + * guarded. For a still-live run, meta also re-arms the pipeline stream. */ + private displaySink(rec: RunRecord): RunSink { + const p = this.pipelines.get(rec.runId); + return { + iter: (r: IterRecord) => { + this.registry.noteIter(rec.runId, r.iter); + getInspector()?.postIterationRecord(r); + this.opts.statusBar?.setRun({ runId: rec.runId, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); + }, + run: (c: RunCompletion) => { + getInspector()?.postCompletion(c.status, c.fidelity); + this.opts.statusBar?.setRun({ runId: c.runId, outputDir: c.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rec.runId)?.latestIter, fidelity: c.fidelity }); + }, + pulse: (e: PulseEvent) => { + if (e.type === "meta") p?.pulses.arm(e.meta); + getInspector()?.postPulse(e); + }, + promote: (info: PromoteInfo) => this.promptPromote(info), + }; + } + + private routeIter(p: RunPipeline, rec: IterRecord): void { + p.dedup.noteIter(rec.iter); + this.registry.noteIter(p.runId, rec.iter); + if (this.selected !== p.runId) return; + getInspector()?.postIterationRecord(rec); + // Live status-bar update — "running · iter N" as it solves (#5 AC3). + this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: "running", latestIter: rec.iter }); + } + + private routePulse(runId: string, e: PulseEvent): void { + if (this.selected !== runId) return; + getInspector()?.postPulse(e); + } + + private checkFinished(p: RunPipeline): void { + if (p.finishedSeen) return; + if (!fs.existsSync(path.join(p.runDir, "FINISHED"))) return; + const t = this.readTerminal(p.runDir); + if (!t) return; // torn/invalid FINISHED — next tick retries + p.finishedSeen = true; + this.completeRun(p.runId, t.status, t.fidelity); + } + + /** Terminal handling for ANY run, selected or not: registry, teardown, + * channel, inspector/status-bar (selected only), promote (any run, once). */ + private completeRun(runId: string, status: RunStatus, fidelity?: number): void { + const rec = this.registry.get(runId); + if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) + this.registry.markFinished(runId, status, fidelity); + const p = this.pipelines.get(runId); + p?.dispose(); + this.pipelines.delete(runId); + this.opts.channel.appendLine(`[runs] ${runId} ${status}${fidelity !== undefined ? ` F=${fidelity.toFixed(6)}` : ""}`); + if (status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); + if (this.selected === runId) { + getInspector()?.postCompletion(status, fidelity); + this.opts.statusBar?.setRun({ runId, outputDir: rec.runDir, startedAt: 0, status, latestIter: rec.latestIter, fidelity }); + } + if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { + this.promptPromote({ runId, runDir: rec.runDir, fidelity }); + } + } + + /** FINISHED (+ result.toml fidelity) with the same validation + say-why + * logging as β (S4: a present-but-invalid result.toml is named, not + * silently dropped). */ + private readTerminal(runDir: string): { status: RunStatus; fidelity?: number } | undefined { + // Delegates to the reader's single orchestration point (review #70 — the + // FINISHED→result.toml sequence must not be maintained twice); only the + // say-why channel is manager-specific. + return readTerminalState(runDir, (why) => this.opts.channel.appendLine(`[runs] ${why}`)); + } + + private promptPromote(info: PromoteInfo): void { + if (this.promotedRuns.has(info.runId)) return; + this.promotedRuns.add(info.runId); + void (async () => { + const choice = await vscode.window.showInformationMessage( + `Amicode: solve converged (F=${info.fidelity.toFixed(4)}). Promote pulse to catalog?`, + "Yes — promote", "No — keep local only", + ); + if (choice === "Yes — promote") { + // #47: record in the session catalog + open the card (store persistence + // is still Phase 3 — the session catalog is workspaceState). Ported from + // file_watcher.ts (Kate's #73), which this manager supersedes. + await vscode.commands.executeCommand("amicode.catalog.save", info.runDir).then(undefined, () => undefined); + } + })(); + } +} diff --git a/packages/extension/test/log_tailer.test.ts b/packages/extension/test/log_tailer.test.ts new file mode 100644 index 00000000..098185e7 --- /dev/null +++ b/packages/extension/test/log_tailer.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LogTailer } from "../src/log_tailer"; + +// LogTailer is now load-bearing for multi-run DISCOVERY (it tails runs/index), +// not just run.log display — pin the buffering semantics the RunsManager +// depends on: newline-delimited emission, torn-line carry-over, truncation +// reset, and the startOffset contract. + +const channel = { appendLine() {}, append() {} } as never; + +function harness(content?: string, startOffset = 0) { + const dir = mkdtempSync(join(tmpdir(), "tail-")); + const p = join(dir, "index"); + if (content !== undefined) writeFileSync(p, content); + const lines: string[] = []; + const t = new LogTailer({ path: p, startOffset, channel, onLine: (l) => lines.push(l) }); + return { p, t, lines }; +} + +describe("LogTailer", () => { + it("emits complete lines once; a torn final line (no newline yet) waits and heals", () => { + const { p, t, lines } = harness("a\t1\t/s.jl\nb\t2\t/s"); // second line torn mid-write + t.poke(); + expect(lines).toEqual(["a\t1\t/s.jl"]); // torn tail NOT emitted + appendFileSync(p, ".jl\nc\t3\t/t.jl\n"); // writer finishes + appends + t.poke(); + expect(lines).toEqual(["a\t1\t/s.jl", "b\t2\t/s.jl", "c\t3\t/t.jl"]); // healed, no split + t.dispose(); + }); + + it("truncation resets to offset 0 and re-reads (consumers must be idempotent)", () => { + const { p, t, lines } = harness("one\ntwo\n"); + t.poke(); + expect(lines).toEqual(["one", "two"]); + writeFileSync(p, "one\n"); // file shrank (rewrite) + t.poke(); + expect(lines).toEqual(["one", "two", "one"]); // full re-read from 0 + t.dispose(); + }); + + it("startOffset skips exactly the replayed bytes (no double-emit, no skipped line)", () => { + const body = "replayed\n"; + const { p, t, lines } = harness(body + "fresh\n", Buffer.byteLength(body, "utf8")); + t.poke(); + expect(lines).toEqual(["fresh"]); + t.dispose(); + }); + + it("poke() self-attaches when the file appears after start()", () => { + const { p, t, lines } = harness(undefined); // file doesn't exist yet + t.poke(); + expect(lines).toEqual([]); + writeFileSync(p, "late\n"); + t.poke(); // attaches + drains + expect(lines).toEqual(["late"]); + t.dispose(); + }); +}); diff --git a/packages/extension/test/run_registry.test.ts b/packages/extension/test/run_registry.test.ts new file mode 100644 index 00000000..d501c91c --- /dev/null +++ b/packages/extension/test/run_registry.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { parseIndexLine, RunRegistry } from "../src/run_registry"; + +// Pure multi-run registry (1.2, #57) — the vscode-free state the RunsManager +// keys on. The index grammar matches amico-run's appendIndex writer +// (`runId\tcreatedAt\tscriptPath\n`, path sanitized of tabs/newlines). + +describe("parseIndexLine — runs/index grammar", () => { + it("parses the writer's TSV line", () => { + expect(parseIndexLine("r20260703-010203Z-ab12\t2026-07-03T01:02:03Z\t/tmp/solve.jl")).toEqual({ + runId: "r20260703-010203Z-ab12", createdAt: "2026-07-03T01:02:03Z", scriptPath: "/tmp/solve.jl", + }); + }); + it("rejects blank and malformed lines (torn final line heals on next drain)", () => { + expect(parseIndexLine("")).toBeUndefined(); + expect(parseIndexLine(" ")).toBeUndefined(); + expect(parseIndexLine("r1\tonly-two-fields")).toBeUndefined(); + expect(parseIndexLine("\t\t/s.jl")).toBeUndefined(); // empty runId + }); + it("re-joins extra tabs into the path (defensive — the writer sanitizes)", () => { + expect(parseIndexLine("r1\t2026-01-01T00:00:00Z\t/a\tb.jl")?.scriptPath).toBe("/a\tb.jl"); + }); +}); + +describe("RunRegistry", () => { + it("registration is idempotent by runId (index replays from 0 every launch)", () => { + const reg = new RunRegistry(); + expect(reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" })).toBe(true); + expect(reg.register({ runId: "r1", runDir: "/elsewhere", phase: "finished" })).toBe(false); + expect(reg.get("r1")?.runDir).toBe("/runs/r1"); // first registration wins + expect(reg.get("r1")?.phase).toBe("live"); + }); + it("noteIter is a monotonic high-water mark", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + reg.noteIter("r1", 5); + reg.noteIter("r1", 3); // out-of-order (poll double-delivery) + expect(reg.get("r1")?.latestIter).toBe(5); + reg.noteIter("nope", 9); // unknown run — no throw + }); + it("markFinished sets phase/status/fidelity and keeps latestIter", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + reg.noteIter("r1", 42); + reg.markFinished("r1", "completed", 0.9991); + expect(reg.get("r1")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9991, latestIter: 42 }); + }); + it("markFinished: first terminal wins — re-marking can't leave a contradictory status/fidelity pair (review #70 #3)", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + reg.markFinished("r1", "completed", 0.999); + reg.markFinished("r1", "failed"); // stray second call (public surface, 1.3 consumers) + expect(reg.get("r1")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.999 }); + }); + it("backfill fills ONLY missing metadata (scheduler-registered run gains createdAt/scriptPath from a later index line)", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); // scheduler path: no metadata + expect(reg.get("r1")?.createdAt).toBeUndefined(); + expect(reg.get("r1")?.scriptPath).toBeUndefined(); + reg.backfill("r1", { createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); + expect(reg.get("r1")).toMatchObject({ createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); + // never overwrites a present value (first registration wins for everything) + reg.backfill("r1", { createdAt: "2099-01-01T00:00:00Z", scriptPath: "/other.jl" }); + expect(reg.get("r1")).toMatchObject({ createdAt: "2026-07-03T00:00:00Z", scriptPath: "/s.jl" }); + reg.backfill("nope", { createdAt: "x" }); // unknown run — no throw + }); + it("all() returns COPIES — callers can't mutate registry state", () => { + const reg = new RunRegistry(); + reg.register({ runId: "r1", runDir: "/runs/r1", phase: "live" }); + const snap = reg.all(); + snap[0].phase = "finished"; + snap[0].latestIter = 999; + expect(reg.get("r1")?.phase).toBe("live"); + expect(reg.get("r1")?.latestIter).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts new file mode 100644 index 00000000..3e1fa499 --- /dev/null +++ b/packages/extension/test/runs_manager.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as vscodeMock from "vscode"; + +// Drive the live RunsManager (1.2, #57) over a temp runs root and assert the +// inspector calls. Ports the RunsRootWatcher state-machine coverage (idle-on- +// finished baseline, warming→completion, #66 pulse routing) onto index-driven +// discovery, and adds the multi-run behaviors: concurrent runs all tracked, +// selection routing, background completion/promote, the Scheduler seam, and +// the explicit-selection demo-replay path. +// +// The inspector is mocked (getInspector() returns spies); `vscode` is the +// aliased stub. tick() is called directly so the poll path is deterministic. + +const { inspector } = vi.hoisted(() => ({ + inspector: { + setWarmingUp: vi.fn(), + postCompletion: vi.fn(), + postIterationRecord: vi.fn(), + postPulse: vi.fn(), + setRunLabel: vi.fn(), + reveal: vi.fn(), + }, +})); +vi.mock("../src/run_inspector", () => ({ getInspector: () => inspector })); + +import { RunsManager, type SchedulerLifecycleEvent, type SchedulerLike } from "../src/runs_manager"; + +const channel = { appendLine() {}, append() {} } as never; +const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; + +function writeManifest(dir: string, runId: string): void { + writeFileSync(join(dir, "run.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`); +} +/** Stage a run dir + its index line (the amico-run writer's TSV format). */ +function stageRun(root: string, runId: string, opts: { finished?: string; fidelity?: number; log?: string } = {}): string { + const dir = join(root, runId); + mkdirSync(dir, { recursive: true }); + writeManifest(dir, runId); + if (opts.log !== undefined) writeFileSync(join(dir, "run.log"), opts.log); + if (opts.fidelity !== undefined) writeFileSync(join(dir, "result.toml"), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = 9\n`); + if (opts.finished) writeFileSync(join(dir, "FINISHED"), `status = "${opts.finished}"\nexit_code = 0\n`); + appendFileSync(join(root, "index"), `${runId}\t2026-07-03T00:00:00Z\t/s.jl\n`); + return dir; +} +const tick = (m: RunsManager): void => (m as unknown as { tick(): void }).tick(); + +describe("RunsManager state machine (ported from RunsRootWatcher)", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("a run already FINISHED at launch stays idle — nothing re-rendered", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "r1", { finished: "completed", fidelity: 0.9999 }); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + tick(m); + expect(inspector.postPulse).not.toHaveBeenCalled(); + expect(inspector.postCompletion).not.toHaveBeenCalled(); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); + expect(m.runs()).toHaveLength(1); // …but it IS registered + expect(m.runs()[0]).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9999 }); + m.dispose(); + }); + + it("fresh run → warming-up → completion (FINISHED-keyed, not result.toml presence)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const run = stageRun(root, "r2"); // manifest only, no data yet + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); + expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); + expect(m.selectedRun).toBe("r2"); + + // result.toml alone must NOT complete the run (FINISHED is authoritative). + writeFileSync(join(run, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 18\n'); + tick(m); + expect(inspector.postCompletion).not.toHaveBeenCalled(); + + writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + tick(m); + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + m.dispose(); + }); + + it("live tail forwards meta and each record in order as they land (#66)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const run = stageRun(root, "p1"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(inspector.postPulse).not.toHaveBeenCalled(); + + writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n"); + tick(m); + expect(inspector.postPulse).toHaveBeenCalledTimes(2); + expect(inspector.postPulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); + expect(inspector.postPulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); + + appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); + tick(m); + expect(inspector.postPulse).toHaveBeenCalledTimes(3); + expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); + m.dispose(); + }); + + it("replay-seeded meta arms the live stream: tailed records flow without a re-sent meta", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + // Mid-flight discovery: meta + one record ALREADY on disk, run not finished. + const run = stageRun(root, "p2", { log: META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n" }); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); // display replay → meta + newest record + expect(inspector.postPulse).toHaveBeenCalledTimes(2); + + appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.3,0.4\n"); + tick(m); // record parses against the armed meta + expect(inspector.postPulse).toHaveBeenCalledTimes(3); + expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); + m.dispose(); + }); +}); + +describe("RunsManager multi-run (#57)", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("two concurrent live runs: newest auto-selected, BOTH tracked to completion", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(m.selectedRun).toBe("rA"); + + const b = stageRun(root, "rB"); // second solve starts + tick(m); // index tail discovers it + expect(m.selectedRun).toBe("rB"); // auto-follow the newest start + inspector.postIterationRecord.mockClear(); + + // Background run A keeps streaming — tracked (registry) but NOT displayed. + appendFileSync(join(a, "run.log"), "AMICODE_ITER iter=7 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); + tick(m); + expect(inspector.postIterationRecord).not.toHaveBeenCalled(); + expect(m.runs().find(r => r.runId === "rA")?.latestIter).toBe(7); + + // A finishes in the background: registry terminal, inspector untouched… + writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9995\niterations = 7\n'); + writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + tick(m); + expect(inspector.postCompletion).not.toHaveBeenCalled(); // rB is selected + expect(m.runs().find(r => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); + // …but the promote prompt STILL fires (fan-out is per-run, not per-selection). + expect(promote).toHaveBeenCalledTimes(1); + + // B completes while selected → completion reaches the inspector. + writeFileSync(join(b, "FINISHED"), 'status = "failed"\nexit_code = 3\n'); + tick(m); + expect(inspector.postCompletion).toHaveBeenCalledWith("failed", undefined); + promote.mockRestore(); + m.dispose(); + }); + + it("selectRun back to a finished run replays its terminal state — promote never re-pops", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); + writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + tick(m); // live completion (promotes once) + stageRun(root, "rB"); + tick(m); // selection moves to rB + expect(m.selectedRun).toBe("rB"); + + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + inspector.postCompletion.mockClear(); + m.selectRun("rA"); // user switches back (1.3 seam) + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(promote).not.toHaveBeenCalled(); // promote-once held + promote.mockRestore(); + m.dispose(); + }); + + it("PULSE events are gated on selection too (not just iter) — background run's plot never reaches the inspector", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rB"); + tick(m); + expect(m.selectedRun).toBe("rB"); // rA now background + inspector.postPulse.mockClear(); + + // A background pulse RECORD on rA must not reach the inspector (rB selected). + appendFileSync(join(a, "run.log"), "AMICODE_PULSE iter=5 dt=0.2 a=0.1,0.2\n"); + tick(m); + expect(inspector.postPulse).not.toHaveBeenCalled(); + m.dispose(); + }); + + it("selecting a run whose FINISHED landed inside the poll window shows completion, never warming", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const a = stageRun(root, "rA"); // live at discovery → pipeline + selected + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rB"); + tick(m); // selection moves to rB (rA still "live" in registry) + inspector.setWarmingUp.mockClear(); + inspector.postCompletion.mockClear(); + + // rA finishes on disk but the poll hasn't ticked (registry still says live). + writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 3\n'); + writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); + m.selectRun("rA"); // user switches back BEFORE the tick + // selectRun re-checks disk → completion, NOT warming (no terminal-badge inversion). + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); + m.dispose(); + }); + + it("an index line naming a missing run dir is tolerated (skipped, no throw)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + writeFileSync(join(root, "index"), "rGone\t2026-07-03T00:00:00Z\t/s.jl\n"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + tick(m); + expect(m.runs()).toHaveLength(0); + m.dispose(); + }); + + it("scheduler `started` registers + selects the run immediately (the #56 seam)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + + let emit!: (e: SchedulerLifecycleEvent) => void; + const scheduler: SchedulerLike = { onEvent: (l) => { emit = l; return () => { /* dispose */ }; } }; + m.attachScheduler(scheduler); + + // A scheduler-launched run — no index line yet (the executor appends it, + // but the started event beats the fs). + const dir = join(root, "rSched"); + mkdirSync(dir); writeManifest(dir, "rSched"); + emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw + emit({ kind: "started", queueId: "q1", runId: "rSched", runDir: dir }); + expect(m.selectedRun).toBe("rSched"); + expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched"); + expect(inspector.setWarmingUp).toHaveBeenCalled(); + + // The index line landing later is a no-op (registration is idempotent). + appendFileSync(join(root, "index"), "rSched\t2026-07-03T00:00:00Z\t/s.jl\n"); + tick(m); + expect(m.runs().filter(r => r.runId === "rSched")).toHaveLength(1); + m.dispose(); + }); + + it("demo replay: a finished run registers quietly; EXPLICIT selection renders it, promote suppressed", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rDemo", { + finished: "completed", fidelity: 0.9998, + log: META_LINE + "AMICODE_PULSE iter=60 dt=0.2 a=0.1,0.2\nAMICODE_ITER iter=60 f=2e-3 inf_pr=1e-9 inf_du=1e-6\n", + }); + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + m.pokeDiscovery(); // same-tick registration… + expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display + m.selectRun("rDemo"); // the replayDemo command's path + expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo"); + expect(inspector.postPulse).toHaveBeenCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); + expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9998); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" + expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt + promote.mockRestore(); + m.dispose(); + }); +}); + +// Review #70 findings — one test per fix (jack-champagne's static/design pass). +describe("RunsManager review-#70 fixes", () => { + beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); + + it("#1 explicit selection is PINNED — a new live run registering does not steal the view", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "rA", { finished: "completed", fidelity: 0.9 }); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + m.selectRun("rA"); // the user deliberately opens rA + expect(m.selectedRun).toBe("rA"); + inspector.setRunLabel.mockClear(); + + stageRun(root, "rB"); // background solve starts + tick(m); + expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin + expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB"); + expect(m.runs().find(r => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked + + m.selectRun("rB"); // explicit switch still works + expect(m.selectedRun).toBe("rB"); + m.dispose(); + }); + + it("#1 auto-follow still applies while nothing was explicitly selected (β latest-follow parity)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "rA"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + stageRun(root, "rB"); + tick(m); + expect(m.selectedRun).toBe("rB"); // no pin → newest live run wins + m.dispose(); + }); + + it("#2 a torn/invalid FINISHED at discovery is retried, not finalized as status:undefined", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const dir = join(root, "rTorn"); + mkdirSync(dir, { recursive: true }); + writeManifest(dir, "rTorn"); + writeFileSync(join(dir, "result.toml"), 'schema_version = "1"\nfidelity = 0.9997\niterations = 5\n'); + writeFileSync(join(dir, "FINISHED"), 'status = "comp'); // torn mid-write: invalid TOML + appendFileSync(join(root, "index"), "rTorn\t2026-07-04T00:00:00Z\t/s.jl\n"); + + const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + // NOT finalized with an undefined status — held live so the retry lane owns it. + expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "live" }); + expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // FINISHED exists on disk — never "warming" + + writeFileSync(join(dir, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); // the write completes + tick(m); + expect(m.runs().find(r => r.runId === "rTorn")).toMatchObject({ phase: "finished", status: "completed", fidelity: 0.9997 }); + expect(promote).not.toHaveBeenCalled(); // still a launch replay — promote suppressed + promote.mockRestore(); + m.dispose(); + }); + + it("#4 discovery ingests the run dir ONCE — no second display pass (registration replay feeds the display)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + stageRun(root, "rOne", { log: META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n" }); + // The old shape ran ingestRunDir twice per discovery: a pipelineSink state + // pass, then auto-follow's selectRun → a displaySink DISPLAY pass over the + // same run.log. displaySink now only backs EXPLICIT selection replays — its + // absence during discovery is the single-pass property. + const displayPass = vi.spyOn(RunsManager.prototype as never as { displaySink(): unknown }, "displaySink"); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(displayPass).not.toHaveBeenCalled(); // was 1 per discovery + // …and the single pass still displayed the history (meta + newest record): + expect(inspector.postPulse).toHaveBeenCalledTimes(2); + displayPass.mockRestore(); + m.dispose(); + }); +}); diff --git a/packages/extension/test/watcher_statemachine.test.ts b/packages/extension/test/watcher_statemachine.test.ts deleted file mode 100644 index 8826f7c2..00000000 --- a/packages/extension/test/watcher_statemachine.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { appendFileSync, 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: { - setWarmingUp: vi.fn(), - postCompletion: vi.fn(), - postIterationRecord: vi.fn(), - postPulse: 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, "run.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(); -const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n'; - -describe("RunsRootWatcher state machine", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); - - it("a run already FINISHED at launch stays idle — nothing re-rendered", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "r1"); mkdirSync(run); - writeManifest(run, "r1"); - 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.postPulse).not.toHaveBeenCalled(); - expect(inspector.postCompletion).not.toHaveBeenCalled(); - expect(inspector.setWarmingUp).not.toHaveBeenCalled(); - w.dispose(); - }); - - it("fresh run → warming-up → completion (plot arrives via pulse routing, not frames)", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "r2"); mkdirSync(run); - writeManifest(run, "r2"); // manifest only, no data yet - setLatest(root, run); - - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); - // fresh run with no data → warming, not idle - expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); - expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); - - // FINISHED + result → terminal completion delivered once - writeFileSync(join(run, "result.toml"), 'schema_version = "1"\nfidelity = 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(); - }); -}); - -// #66 AC6 — live-tail pulse routing. On a live run the tailer is the ONLY -// carrier of meta (ingest sees an empty log at run start), so the tail path -// must forward meta AND each record, in order. -describe("pulse-line routing (#66)", () => { - beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); - - it("live tail forwards meta and each record in order as they land", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "p1"); mkdirSync(run); - writeManifest(run, "p1"); - setLatest(root, run); - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); // fresh run, empty log → warming - expect(inspector.postPulse).not.toHaveBeenCalled(); - - writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n"); - tick(w); // poll poke drains the tailer - expect(inspector.postPulse).toHaveBeenCalledTimes(2); - expect(inspector.postPulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); - expect(inspector.postPulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); - - appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); - tick(w); - expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); - w.dispose(); - }); - - it("replay-seeded meta arms the live stream: tailed records flow without a re-sent meta", () => { - const root = mkdtempSync(join(tmpdir(), "runs-")); - const run = join(root, "p2"); mkdirSync(run); - writeManifest(run, "p2"); - // Mid-flight switch: meta + one record ALREADY on disk, run not finished. - writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=3 dt=0.2 a=0.1,0.2\n"); - setLatest(root, run); - const w = new RunsRootWatcher({ runsRoot: root, channel }); - w.start(); // ingest replays meta + newest record - expect(inspector.postPulse).toHaveBeenCalledTimes(2); - - // a record tailed AFTER attach, with no meta line in the tailed region - appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.5,0.6\n"); - tick(w); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); - w.dispose(); - }); -});